From 7e758ff09c99c8dff86ed4572318d00e8050baf2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 22:05:13 -0600 Subject: [PATCH 01/31] Add preliminary full-stack tests --- .../controllers/test_controller.ex | 72 +++++++++ registrations/lib/registrations_web/router.ex | 8 + .../integration_test/test_backend_client.dart | 90 +++++++++++ .../integration_test/test_config.dart | 10 ++ .../integration_test/token_refresh_test.dart | 147 ++++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 registrations/lib/registrations_web/controllers/test_controller.ex create mode 100644 waydowntown_app/integration_test/test_backend_client.dart create mode 100644 waydowntown_app/integration_test/test_config.dart create mode 100644 waydowntown_app/integration_test/token_refresh_test.dart diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex new file mode 100644 index 00000000..ccf2d7c2 --- /dev/null +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -0,0 +1,72 @@ +defmodule RegistrationsWeb.TestController do + @moduledoc """ + Controller for test-only endpoints. Only available in test environment. + Provides database reset functionality for integration testing. + """ + use RegistrationsWeb, :controller + + alias Registrations.Repo + alias Registrations.Waydowntown.Answer + alias Registrations.Waydowntown.Participation + alias Registrations.Waydowntown.Region + alias Registrations.Waydowntown.Reveal + alias Registrations.Waydowntown.Run + alias Registrations.Waydowntown.Specification + alias Registrations.Waydowntown.Submission + + @test_email "test@example.com" + @test_password "TestPassword123!" + + def reset(conn, params) do + # Delete waydowntown tables in FK order + Repo.delete_all(Reveal) + Repo.delete_all(Submission) + Repo.delete_all(Participation) + Repo.delete_all(Run) + Repo.delete_all(Answer) + Repo.delete_all(Specification) + Repo.delete_all(Region) + + response = + if params["create_user"] == "true" do + user = create_or_reset_test_user() + + %{ + user_id: user.id, + email: @test_email, + password: @test_password + } + else + %{message: "Database reset complete"} + end + + conn + |> put_status(:ok) + |> json(response) + end + + defp create_or_reset_test_user do + case Repo.get_by(RegistrationsWeb.User, email: @test_email) do + nil -> + %RegistrationsWeb.User{} + |> RegistrationsWeb.User.changeset(%{ + email: @test_email, + password: @test_password, + password_confirmation: @test_password + }) + |> Repo.insert!() + + existing_user -> + # Delete and recreate to ensure clean state + Repo.delete!(existing_user) + + %RegistrationsWeb.User{} + |> RegistrationsWeb.User.changeset(%{ + email: @test_email, + password: @test_password, + password_confirmation: @test_password + }) + |> Repo.insert!() + end + end +end diff --git a/registrations/lib/registrations_web/router.ex b/registrations/lib/registrations_web/router.ex index 0c2877a9..9af0946f 100644 --- a/registrations/lib/registrations_web/router.ex +++ b/registrations/lib/registrations_web/router.ex @@ -168,4 +168,12 @@ defmodule RegistrationsWeb.Router do get("/session", SessionController, :show) post("/me", ApiUserController, :update) end + + if Mix.env() == :test do + scope "/test", RegistrationsWeb do + pipe_through(:pow_api) + + post("/reset", TestController, :reset) + end + end end diff --git a/waydowntown_app/integration_test/test_backend_client.dart b/waydowntown_app/integration_test/test_backend_client.dart new file mode 100644 index 00000000..d606454c --- /dev/null +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -0,0 +1,90 @@ +import 'package:dio/dio.dart'; + +import 'test_config.dart'; + +/// Credentials for a test user returned by the backend's reset endpoint. +class TestUserCredentials { + final String userId; + final String email; + final String password; + + TestUserCredentials({ + required this.userId, + required this.email, + required this.password, + }); + + factory TestUserCredentials.fromJson(Map json) { + return TestUserCredentials( + userId: json['user_id'], + email: json['email'], + password: json['password'], + ); + } +} + +/// Tokens returned by the login endpoint. +class TestTokens { + final String accessToken; + final String renewalToken; + + TestTokens({ + required this.accessToken, + required this.renewalToken, + }); +} + +/// HTTP client for test setup operations against the backend. +class TestBackendClient { + final Dio _dio; + final String baseUrl; + + TestBackendClient({String? baseUrl}) + : baseUrl = baseUrl ?? TestConfig.apiBaseUrl, + _dio = Dio(BaseOptions( + baseUrl: baseUrl ?? TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + /// Resets the database and optionally creates a test user. + /// Returns credentials if [createUser] is true. + Future resetDatabase({bool createUser = true}) async { + final response = await _dio.post( + '/test/reset', + data: { + 'create_user': createUser ? 'true' : 'false', + }, + ); + + if (createUser && response.statusCode == 200) { + return TestUserCredentials.fromJson(response.data); + } + return null; + } + + /// Logs in with the given credentials and returns tokens. + Future login(String email, String password) async { + final response = await _dio.post( + '/powapi/session', + data: { + 'user': { + 'email': email, + 'password': password, + }, + }, + ); + + if (response.statusCode != 200) { + throw Exception('Login failed with status ${response.statusCode}'); + } + + final data = response.data['data']; + return TestTokens( + accessToken: data['access_token'], + renewalToken: data['renewal_token'], + ); + } +} diff --git a/waydowntown_app/integration_test/test_config.dart b/waydowntown_app/integration_test/test_config.dart new file mode 100644 index 00000000..a4eab1f4 --- /dev/null +++ b/waydowntown_app/integration_test/test_config.dart @@ -0,0 +1,10 @@ +/// Configuration for integration tests that run against a real backend. +class TestConfig { + /// API base URL for the test backend. + /// Can be overridden via --dart-define=API_BASE_URL= + /// Default: http://localhost:4001 + static const String apiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:4001', + ); +} diff --git a/waydowntown_app/integration_test/token_refresh_test.dart b/waydowntown_app/integration_test/token_refresh_test.dart new file mode 100644 index 00000000..45b239f7 --- /dev/null +++ b/waydowntown_app/integration_test/token_refresh_test.dart @@ -0,0 +1,147 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + late TestUserCredentials credentials; + late TestTokens tokens; + + setUp(() async { + // Initialize mock storage for tests + FlutterSecureStorage.setMockInitialValues({}); + + testClient = TestBackendClient(); + + // Reset database and create test user + final creds = await testClient.resetDatabase(createUser: true); + credentials = creds!; + + // Login to get tokens + tokens = await testClient.login(credentials.email, credentials.password); + + // Store tokens in secure storage + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + }); + + testWidgets('login and access protected resource', (tester) async { + // Create Dio instances matching the app.dart pattern + final dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + final renewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + )); + + final postRenewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + dio.interceptors.add(RefreshTokenInterceptor( + dio: dio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + + // Access a protected endpoint (session check) + final sessionDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + sessionDio.interceptors.add(RefreshTokenInterceptor( + dio: sessionDio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + + final response = await sessionDio.get('/fixme/session'); + + expect(response.statusCode, equals(200)); + expect(response.data['data']['attributes']['email'], + equals(credentials.email)); + }); + + testWidgets('token refresh works with invalid access token + valid renewal token', + (tester) async { + // Invalidate the access token but keep valid renewal token + await UserService.setTokens('invalid_access_token', tokens.renewalToken); + + // Create Dio instances matching the app.dart pattern + final dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + final renewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + )); + + final postRenewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + dio.interceptors.add(RefreshTokenInterceptor( + dio: dio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + + // Access protected endpoint - should fail initially, then refresh and succeed + final sessionDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + sessionDio.interceptors.add(RefreshTokenInterceptor( + dio: sessionDio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + + final response = await sessionDio.get('/fixme/session'); + + // The interceptor should have refreshed the token and retried + expect(response.statusCode, equals(200)); + expect(response.data['data']['attributes']['email'], + equals(credentials.email)); + + // Verify tokens were updated + final newAccessToken = await UserService.getAccessToken(); + final newRenewalToken = await UserService.getRenewalToken(); + + expect(newAccessToken, isNot(equals('invalid_access_token'))); + expect(newAccessToken, isNotNull); + expect(newRenewalToken, isNotNull); + }); +} From 70ebba404cb98e7d16f7d6f32aed7d89faa279e9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 22:07:44 -0600 Subject: [PATCH 02/31] Add preliminary CI setup --- .github/workflows/ci-full-stack.yml | 90 +++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/workflows/ci-full-stack.yml diff --git a/.github/workflows/ci-full-stack.yml b/.github/workflows/ci-full-stack.yml new file mode 100644 index 00000000..8ce7d68d --- /dev/null +++ b/.github/workflows/ci-full-stack.yml @@ -0,0 +1,90 @@ +name: Full-stack integration tests + +on: + push: + branches: [main] + pull_request: + paths: + - "waydowntown_app/**" + - "registrations/**" + - ".github/workflows/ci-full-stack.yml" + +jobs: + integration-test: + runs-on: ubuntu-latest + services: + db: + image: postgis/postgis:16-3.4 + ports: ["5432:5432"] + env: + POSTGRES_PASSWORD: postgres + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + # Setup Elixir/Erlang + - name: Setup Erlang/OTP environment + uses: erlef/setup-beam@v1 + with: + version-file: .tool-versions + version-type: strict + + - name: Retrieve cached Elixir dependencies + uses: actions/cache@v4 + id: mix-cache + with: + path: | + registrations/deps + registrations/_build + key: ${{ runner.os }}-mix-${{ hashFiles('registrations/mix.lock') }} + + - name: Install Elixir dependencies + working-directory: registrations + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + mix deps.compile + + # Setup and start Phoenix + - name: Setup database + working-directory: registrations + run: MIX_ENV=test mix ecto.reset + + - name: Start Phoenix server + working-directory: registrations + run: | + MIX_ENV=test mix phx.server & + # Wait for server to be ready + for i in {1..30}; do + if curl -s http://localhost:4001/powapi/session > /dev/null 2>&1; then + echo "Phoenix server is ready" + break + fi + echo "Waiting for Phoenix server... ($i)" + sleep 1 + done + + # Setup Flutter + - uses: subosito/flutter-action@v2 + with: + channel: "stable" + + - name: Create Flutter env file + run: touch .env.local + working-directory: waydowntown_app + + - name: Get Flutter dependencies + run: flutter pub get + working-directory: waydowntown_app + + # Run integration tests on Android emulator + - name: Run full-stack integration tests + uses: reactivecircus/android-emulator-runner@v2 + with: + working-directory: waydowntown_app + api-level: 21 + arch: x86_64 + profile: Nexus 6 + script: flutter test integration_test/ --dart-define=API_BASE_URL=http://10.0.2.2:4001 From 996f21a6570739bd592e35edee21de0551b344df Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 22:30:02 -0600 Subject: [PATCH 03/31] Change workflow name --- .../{ci-full-stack.yml => ci-waydowntown-full-stack.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{ci-full-stack.yml => ci-waydowntown-full-stack.yml} (98%) diff --git a/.github/workflows/ci-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml similarity index 98% rename from .github/workflows/ci-full-stack.yml rename to .github/workflows/ci-waydowntown-full-stack.yml index 8ce7d68d..1e51b39f 100644 --- a/.github/workflows/ci-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -1,4 +1,4 @@ -name: Full-stack integration tests +name: waydowntown full-stack tests on: push: From 225b19bfcb0bbc9a35515ae5ffeded3502c59ab4 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 22:30:40 -0600 Subject: [PATCH 04/31] Update Android version --- .github/workflows/ci-waydowntown-full-stack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 1e51b39f..70a25435 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -84,7 +84,7 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: working-directory: waydowntown_app - api-level: 21 + api-level: 24 arch: x86_64 profile: Nexus 6 script: flutter test integration_test/ --dart-define=API_BASE_URL=http://10.0.2.2:4001 From 5b043ee25287461e8e9bf6093edeaf36bb91172c Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 22:37:53 -0600 Subject: [PATCH 05/31] Change job name --- .github/workflows/ci-waydowntown-full-stack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 70a25435..402b6c81 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -10,7 +10,7 @@ on: - ".github/workflows/ci-full-stack.yml" jobs: - integration-test: + waydowntown-full-stack-test: runs-on: ubuntu-latest services: db: From dbbae780ef9861603986603ba0b966161f29e8c4 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 22:55:16 -0600 Subject: [PATCH 06/31] Update Gradle etc --- .github/workflows/ci-waydowntown-app.yml | 5 +++++ .github/workflows/ci-waydowntown-full-stack.yml | 8 +++++++- waydowntown_app/android/app/build.gradle | 8 ++++++-- .../android/gradle/wrapper/gradle-wrapper.properties | 2 +- waydowntown_app/android/settings.gradle | 2 +- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-waydowntown-app.yml b/.github/workflows/ci-waydowntown-app.yml index 28f2133f..00a4e81f 100644 --- a/.github/workflows/ci-waydowntown-app.yml +++ b/.github/workflows/ci-waydowntown-app.yml @@ -39,6 +39,11 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - uses: subosito/flutter-action@v2 with: channel: "stable" diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 402b6c81..f9db039e 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -7,7 +7,7 @@ on: paths: - "waydowntown_app/**" - "registrations/**" - - ".github/workflows/ci-full-stack.yml" + - ".github/workflows/ci-waydowntown-full-stack.yml" jobs: waydowntown-full-stack-test: @@ -66,6 +66,12 @@ jobs: sleep 1 done + # Setup Java 17 (required for AGP 8.x) + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + # Setup Flutter - uses: subosito/flutter-action@v2 with: diff --git a/waydowntown_app/android/app/build.gradle b/waydowntown_app/android/app/build.gradle index 0887f662..b02b1fea 100644 --- a/waydowntown_app/android/app/build.gradle +++ b/waydowntown_app/android/app/build.gradle @@ -54,8 +54,12 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" } defaultConfig { diff --git a/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties b/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties index e1ca574e..7bb2df6b 100644 --- a/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/waydowntown_app/android/settings.gradle b/waydowntown_app/android/settings.gradle index 4905f4ae..95a50783 100644 --- a/waydowntown_app/android/settings.gradle +++ b/waydowntown_app/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version "8.1.0" apply false id "org.jetbrains.kotlin.android" version "2.0.20" apply false } From 03c9455112f0507b44c2caa42966dd0b9edc5805 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 23:14:20 -0600 Subject: [PATCH 07/31] Add Gradle workaround --- waydowntown_app/android/build.gradle | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/waydowntown_app/android/build.gradle b/waydowntown_app/android/build.gradle index d2ffbffa..438355b5 100644 --- a/waydowntown_app/android/build.gradle +++ b/waydowntown_app/android/build.gradle @@ -13,6 +13,24 @@ subprojects { project.evaluationDependsOn(":app") } +// Fix for AGP 8.x: auto-set namespace for plugins that don't specify one +subprojects { + afterEvaluate { project -> + if (project.plugins.hasPlugin("com.android.library")) { + def android = project.extensions.findByName("android") + if (android != null && (android.namespace == null || android.namespace.isEmpty())) { + def manifest = project.file("src/main/AndroidManifest.xml") + if (manifest.exists()) { + def packageName = new groovy.xml.XmlSlurper().parse(manifest).@package.toString() + if (packageName) { + android.namespace = packageName + } + } + } + } + } +} + tasks.register("clean", Delete) { delete rootProject.buildDir } From e58388e58523fa67577fb44e2b2e26bb4feb4e71 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 4 Feb 2026 23:30:29 -0600 Subject: [PATCH 08/31] Change Gradle fix --- waydowntown_app/android/build.gradle | 33 +++++++++++++--------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/waydowntown_app/android/build.gradle b/waydowntown_app/android/build.gradle index 438355b5..24ccbe61 100644 --- a/waydowntown_app/android/build.gradle +++ b/waydowntown_app/android/build.gradle @@ -3,6 +3,21 @@ allprojects { google() mavenCentral() } + + // Fix for AGP 8.x: auto-set namespace for plugins that don't specify one + pluginManager.withPlugin("com.android.library") { + def androidExtension = project.extensions.getByType(com.android.build.gradle.LibraryExtension) + if (androidExtension.namespace == null) { + def manifestFile = project.file("src/main/AndroidManifest.xml") + if (manifestFile.exists()) { + def manifest = new XmlSlurper().parse(manifestFile) + def packageName = manifest.@package.toString() + if (packageName) { + androidExtension.namespace = packageName + } + } + } + } } rootProject.buildDir = "../build" @@ -13,24 +28,6 @@ subprojects { project.evaluationDependsOn(":app") } -// Fix for AGP 8.x: auto-set namespace for plugins that don't specify one -subprojects { - afterEvaluate { project -> - if (project.plugins.hasPlugin("com.android.library")) { - def android = project.extensions.findByName("android") - if (android != null && (android.namespace == null || android.namespace.isEmpty())) { - def manifest = project.file("src/main/AndroidManifest.xml") - if (manifest.exists()) { - def packageName = new groovy.xml.XmlSlurper().parse(manifest).@package.toString() - if (packageName) { - android.namespace = packageName - } - } - } - } - } -} - tasks.register("clean", Delete) { delete rootProject.buildDir } From bc9c5878590ab3b1c51cad9b10f0fd3150c289a7 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 09:12:08 -0600 Subject: [PATCH 09/31] Fix build by switching motion sensors package --- waydowntown_app/android/build.gradle | 21 ++++++++++++++++++- .../gradle/wrapper/gradle-wrapper.properties | 2 +- waydowntown_app/android/settings.gradle | 2 +- .../lib/games/cardinal_memory.dart | 2 +- .../lib/games/orientation_memory.dart | 2 +- .../lib/tools/motion_sensors_route.dart | 2 +- waydowntown_app/pubspec.lock | 17 +++++++-------- waydowntown_app/pubspec.yaml | 5 +---- .../test/games/cardinal_memory_test.dart | 2 +- .../games/cardinal_memory_test.mocks.dart | 2 +- .../test/games/orientation_memory_test.dart | 2 +- .../games/orientation_memory_test.mocks.dart | 2 +- 12 files changed, 38 insertions(+), 23 deletions(-) diff --git a/waydowntown_app/android/build.gradle b/waydowntown_app/android/build.gradle index 24ccbe61..ff31a160 100644 --- a/waydowntown_app/android/build.gradle +++ b/waydowntown_app/android/build.gradle @@ -4,9 +4,11 @@ allprojects { mavenCentral() } - // Fix for AGP 8.x: auto-set namespace for plugins that don't specify one + // Fix for AGP 8.x: auto-set namespace for library plugins pluginManager.withPlugin("com.android.library") { def androidExtension = project.extensions.getByType(com.android.build.gradle.LibraryExtension) + + // Set namespace from manifest if not specified if (androidExtension.namespace == null) { def manifestFile = project.file("src/main/AndroidManifest.xml") if (manifestFile.exists()) { @@ -20,6 +22,23 @@ allprojects { } } +// Force Java 17 and Kotlin JVM target 17 for all subprojects AFTER they're evaluated +gradle.afterProject { project -> + if (project.plugins.hasPlugin("com.android.library")) { + project.android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "17" + } + } + } +} + rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" diff --git a/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties b/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6b..3c85cfe0 100644 --- a/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/waydowntown_app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/waydowntown_app/android/settings.gradle b/waydowntown_app/android/settings.gradle index 95a50783..b58537ff 100644 --- a/waydowntown_app/android/settings.gradle +++ b/waydowntown_app/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false + id "com.android.application" version "8.6.0" apply false id "org.jetbrains.kotlin.android" version "2.0.20" apply false } diff --git a/waydowntown_app/lib/games/cardinal_memory.dart b/waydowntown_app/lib/games/cardinal_memory.dart index e71850b7..6c7f65f0 100644 --- a/waydowntown_app/lib/games/cardinal_memory.dart +++ b/waydowntown_app/lib/games/cardinal_memory.dart @@ -3,7 +3,7 @@ import 'dart:math' as math; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:phoenix_socket/phoenix_socket.dart'; import 'package:waydowntown/mixins/run_state_mixin.dart'; import 'package:waydowntown/models/run.dart'; diff --git a/waydowntown_app/lib/games/orientation_memory.dart b/waydowntown_app/lib/games/orientation_memory.dart index 6795e10e..d3dd69ec 100644 --- a/waydowntown_app/lib/games/orientation_memory.dart +++ b/waydowntown_app/lib/games/orientation_memory.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:phoenix_socket/phoenix_socket.dart'; import 'package:waydowntown/mixins/run_state_mixin.dart'; import 'package:waydowntown/models/run.dart'; diff --git a/waydowntown_app/lib/tools/motion_sensors_route.dart b/waydowntown_app/lib/tools/motion_sensors_route.dart index 74c69de3..4eff3729 100644 --- a/waydowntown_app/lib/tools/motion_sensors_route.dart +++ b/waydowntown_app/lib/tools/motion_sensors_route.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() { diff --git a/waydowntown_app/pubspec.lock b/waydowntown_app/pubspec.lock index 6e5e1dbf..b14e6f73 100644 --- a/waydowntown_app/pubspec.lock +++ b/waydowntown_app/pubspec.lock @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.6" + dchs_motion_sensors: + dependency: "direct main" + description: + name: dchs_motion_sensors + sha256: "2a6eec0c47fd59d0f923aa758286ded0d74ae79519dec560d003e6d0797877e2" + url: "https://pub.dev" + source: hosted + version: "2.0.1" dio: dependency: "direct main" description: @@ -759,15 +767,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.4.4" - motion_sensors: - dependency: "direct main" - description: - path: "." - ref: "6dafc3639b3e96460fabc639768a60b431b53610" - resolved-ref: "6dafc3639b3e96460fabc639768a60b431b53610" - url: "https://github.com/zesage/motion_sensors.git" - source: git - version: "0.1.0" nested: dependency: transitive description: diff --git a/waydowntown_app/pubspec.yaml b/waydowntown_app/pubspec.yaml index 2894237a..0cd37757 100644 --- a/waydowntown_app/pubspec.yaml +++ b/waydowntown_app/pubspec.yaml @@ -47,10 +47,7 @@ dependencies: mobile_scanner: ^5.1.1 latlong2: ^0.9.1 flutter_native_splash: ^2.4.1 - motion_sensors: - git: - url: https://github.com/zesage/motion_sensors.git - ref: 6dafc3639b3e96460fabc639768a60b431b53610 + dchs_motion_sensors: ^2.0.1 vector_math: ^2.1.4 yaml: ^3.1.2 flutter_confetti: ^0.3.0 diff --git a/waydowntown_app/test/games/cardinal_memory_test.dart b/waydowntown_app/test/games/cardinal_memory_test.dart index 612aea20..dd7d8af1 100644 --- a/waydowntown_app/test/games/cardinal_memory_test.dart +++ b/waydowntown_app/test/games/cardinal_memory_test.dart @@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:waydowntown/games/cardinal_memory.dart'; import 'package:waydowntown/models/answer.dart'; diff --git a/waydowntown_app/test/games/cardinal_memory_test.mocks.dart b/waydowntown_app/test/games/cardinal_memory_test.mocks.dart index 7c8644c6..d3ebaa21 100644 --- a/waydowntown_app/test/games/cardinal_memory_test.mocks.dart +++ b/waydowntown_app/test/games/cardinal_memory_test.mocks.dart @@ -5,9 +5,9 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart' as _i4; import 'package:flutter/services.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:motion_sensors/motion_sensors.dart' as _i4; import 'package:vector_math/vector_math_64.dart' as _i3; // ignore_for_file: type=lint diff --git a/waydowntown_app/test/games/orientation_memory_test.dart b/waydowntown_app/test/games/orientation_memory_test.dart index b237ccb6..d05e71a7 100644 --- a/waydowntown_app/test/games/orientation_memory_test.dart +++ b/waydowntown_app/test/games/orientation_memory_test.dart @@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'package:motion_sensors/motion_sensors.dart'; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:waydowntown/games/orientation_memory.dart'; import 'package:waydowntown/models/answer.dart'; diff --git a/waydowntown_app/test/games/orientation_memory_test.mocks.dart b/waydowntown_app/test/games/orientation_memory_test.mocks.dart index 532f007c..3a333ac4 100644 --- a/waydowntown_app/test/games/orientation_memory_test.mocks.dart +++ b/waydowntown_app/test/games/orientation_memory_test.mocks.dart @@ -5,9 +5,9 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i5; +import 'package:dchs_motion_sensors/dchs_motion_sensors.dart' as _i4; import 'package:flutter/services.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:motion_sensors/motion_sensors.dart' as _i4; import 'package:vector_math/vector_math_64.dart' as _i3; // ignore_for_file: type=lint From f2a0e0fcebad7ea3923afde671d368ddad960eab Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 10:03:41 -0600 Subject: [PATCH 10/31] Add routing to API --- .github/workflows/ci-waydowntown-full-stack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index f9db039e..82a9bd8f 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,4 +93,4 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/ --dart-define=API_BASE_URL=http://10.0.2.2:4001 + script: flutter test integration_test/ --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 From bd986a0c306498618feedeba15506ffb60eba549 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 10:38:20 -0600 Subject: [PATCH 11/31] Change Phoenix binding in test environment --- registrations/config/test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registrations/config/test.exs b/registrations/config/test.exs index 07e66577..3128ce8f 100644 --- a/registrations/config/test.exs +++ b/registrations/config/test.exs @@ -26,5 +26,5 @@ config :registrations, Registrations.Repo, pool: Ecto.Adapters.SQL.Sandbox config :registrations, RegistrationsWeb.Endpoint, - http: [port: 4001], + http: [ip: {0, 0, 0, 0}, port: 4001], server: true From 08d59ddc111d5215857b0777e7465a60cd8f3654 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 12:08:41 -0600 Subject: [PATCH 12/31] Add fill_in_the_blank integration test - Extend TestController to create test game data via 'game' parameter - Add TestGameData and TestSetupData classes to test client - Create fill_in_the_blank_test.dart testing full game flow: - Create and start run - Submit wrong answer (verify not complete) - Submit correct answer (verify win) - Test case-insensitive matching Co-Authored-By: Claude Opus 4.5 --- .../controllers/test_controller.ex | 33 ++- .../fill_in_the_blank_test.dart | 211 ++++++++++++++++++ .../integration_test/test_backend_client.dart | 50 ++++- .../integration_test/token_refresh_test.dart | 12 +- 4 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 waydowntown_app/integration_test/fill_in_the_blank_test.dart diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index ccf2d7c2..a3eafa63 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -31,11 +31,21 @@ defmodule RegistrationsWeb.TestController do if params["create_user"] == "true" do user = create_or_reset_test_user() - %{ + base_response = %{ user_id: user.id, email: @test_email, password: @test_password } + + # Optionally create game data based on concept parameter + case params["game"] do + "fill_in_the_blank" -> + game_data = create_fill_in_the_blank_game() + Map.merge(base_response, game_data) + + _ -> + base_response + end else %{message: "Database reset complete"} end @@ -45,6 +55,27 @@ defmodule RegistrationsWeb.TestController do |> json(response) end + defp create_fill_in_the_blank_game do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "fill_in_the_blank", + task_description: "What is the answer to this test?", + answers: [%Answer{label: "The answer is ____", answer: "correct"}], + region: region, + duration: 300 + }) + + answer = List.first(specification.answers) + + %{ + specification_id: specification.id, + answer_id: answer.id, + correct_answer: "correct" + } + end + defp create_or_reset_test_user do case Repo.get_by(RegistrationsWeb.User, email: @test_email) do nil -> diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart new file mode 100644 index 00000000..4786da71 --- /dev/null +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -0,0 +1,211 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + late TestSetupData setupData; + late TestTokens tokens; + late Dio dio; + + setUp(() async { + // Initialize mock storage for tests + FlutterSecureStorage.setMockInitialValues({}); + + testClient = TestBackendClient(); + + // Reset database and create test user with fill_in_the_blank game + final data = await testClient.resetDatabase( + createUser: true, + game: 'fill_in_the_blank', + ); + setupData = data!; + + // Login to get tokens + tokens = await testClient.login( + setupData.credentials.email, + setupData.credentials.password, + ); + + // Store tokens in secure storage + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + // Create authenticated Dio instance + dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + final renewalDio = Dio(BaseOptions(baseUrl: TestConfig.apiBaseUrl)); + final postRenewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + dio.interceptors.add(RefreshTokenInterceptor( + dio: dio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + }); + + testWidgets('fill_in_the_blank game: correct answer wins', + (WidgetTester tester) async { + // Verify game data was created + expect(setupData.gameData, isNotNull); + expect(setupData.gameData!.correctAnswer, equals('correct')); + + // Step 1: Create a run (this also creates a participation) + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=fill_in_the_blank', + data: { + 'data': { + 'type': 'runs', + 'attributes': {}, + }, + }, + ); + + expect(createRunResponse.statusCode, equals(201)); + final runId = createRunResponse.data['data']['id']; + expect(runId, isNotNull); + + // Verify specification is included + final included = createRunResponse.data['included'] as List; + final specData = included.firstWhere((i) => i['type'] == 'specifications'); + expect(specData['attributes']['concept'], equals('fill_in_the_blank')); + expect(specData['attributes']['task_description'], + equals('What is the answer to this test?')); + + // Verify answer is included (with label but not the actual answer) + final answerData = included.firstWhere((i) => i['type'] == 'answers'); + expect(answerData['attributes']['label'], equals('The answer is ____')); + + // Step 2: Start the run + final startRunResponse = await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': { + 'type': 'runs', + 'id': runId, + }, + }, + ); + + expect(startRunResponse.statusCode, equals(200)); + expect( + startRunResponse.data['data']['attributes']['started_at'], isNotNull); + + // Step 3: Submit an incorrect answer first + final wrongSubmissionResponse = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'wrong answer'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + + expect(wrongSubmissionResponse.statusCode, equals(201)); + expect( + wrongSubmissionResponse.data['data']['attributes']['correct'], isFalse); + + // Verify run is not complete yet + final wrongIncluded = wrongSubmissionResponse.data['included'] as List; + final runAfterWrong = + wrongIncluded.firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runAfterWrong['attributes']['complete'], isFalse); + + // Step 4: Submit the correct answer + final correctSubmissionResponse = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'correct'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + + expect(correctSubmissionResponse.statusCode, equals(201)); + expect(correctSubmissionResponse.data['data']['attributes']['correct'], + isTrue); + + // Verify run is now complete + final correctIncluded = correctSubmissionResponse.data['included'] as List; + final runAfterCorrect = correctIncluded + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runAfterCorrect['attributes']['complete'], isTrue); + }); + + testWidgets('fill_in_the_blank game: case insensitive matching', + (WidgetTester tester) async { + expect(setupData.gameData, isNotNull); + + // Create and start a run + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=fill_in_the_blank', + data: { + 'data': {'type': 'runs', 'attributes': {}}, + }, + ); + final runId = createRunResponse.data['data']['id']; + + await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': {'type': 'runs', 'id': runId}, + }, + ); + + // Submit answer with different case and whitespace + final submissionResponse = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': ' CORRECT '}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + + expect(submissionResponse.statusCode, equals(201)); + expect(submissionResponse.data['data']['attributes']['correct'], isTrue); + + // Verify run is complete + final included = submissionResponse.data['included'] as List; + final runData = + included.firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['complete'], isTrue); + }); +} diff --git a/waydowntown_app/integration_test/test_backend_client.dart b/waydowntown_app/integration_test/test_backend_client.dart index d606454c..48e8f317 100644 --- a/waydowntown_app/integration_test/test_backend_client.dart +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -23,6 +23,38 @@ class TestUserCredentials { } } +/// Game data returned when creating test games. +class TestGameData { + final String specificationId; + final String answerId; + final String correctAnswer; + + TestGameData({ + required this.specificationId, + required this.answerId, + required this.correctAnswer, + }); + + factory TestGameData.fromJson(Map json) { + return TestGameData( + specificationId: json['specification_id'], + answerId: json['answer_id'], + correctAnswer: json['correct_answer'], + ); + } +} + +/// Combined response from reset endpoint with user and optional game data. +class TestSetupData { + final TestUserCredentials credentials; + final TestGameData? gameData; + + TestSetupData({ + required this.credentials, + this.gameData, + }); +} + /// Tokens returned by the login endpoint. class TestTokens { final String accessToken; @@ -49,18 +81,30 @@ class TestBackendClient { }, )); - /// Resets the database and optionally creates a test user. + /// Resets the database and optionally creates a test user and game. /// Returns credentials if [createUser] is true. - Future resetDatabase({bool createUser = true}) async { + /// If [game] is specified, creates test data for that game type. + Future resetDatabase({ + bool createUser = true, + String? game, + }) async { final response = await _dio.post( '/test/reset', data: { 'create_user': createUser ? 'true' : 'false', + if (game != null) 'game': game, }, ); if (createUser && response.statusCode == 200) { - return TestUserCredentials.fromJson(response.data); + final credentials = TestUserCredentials.fromJson(response.data); + TestGameData? gameData; + + if (game != null && response.data['specification_id'] != null) { + gameData = TestGameData.fromJson(response.data); + } + + return TestSetupData(credentials: credentials, gameData: gameData); } return null; } diff --git a/waydowntown_app/integration_test/token_refresh_test.dart b/waydowntown_app/integration_test/token_refresh_test.dart index 45b239f7..9c411afa 100644 --- a/waydowntown_app/integration_test/token_refresh_test.dart +++ b/waydowntown_app/integration_test/token_refresh_test.dart @@ -12,7 +12,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); late TestBackendClient testClient; - late TestUserCredentials credentials; + late TestSetupData setupData; late TestTokens tokens; setUp(() async { @@ -22,11 +22,11 @@ void main() { testClient = TestBackendClient(); // Reset database and create test user - final creds = await testClient.resetDatabase(createUser: true); - credentials = creds!; + final data = await testClient.resetDatabase(createUser: true); + setupData = data!; // Login to get tokens - tokens = await testClient.login(credentials.email, credentials.password); + tokens = await testClient.login(setupData.credentials.email, setupData.credentials.password); // Store tokens in secure storage await UserService.setTokens(tokens.accessToken, tokens.renewalToken); @@ -79,7 +79,7 @@ void main() { expect(response.statusCode, equals(200)); expect(response.data['data']['attributes']['email'], - equals(credentials.email)); + equals(setupData.credentials.email)); }); testWidgets('token refresh works with invalid access token + valid renewal token', @@ -134,7 +134,7 @@ void main() { // The interceptor should have refreshed the token and retried expect(response.statusCode, equals(200)); expect(response.data['data']['attributes']['email'], - equals(credentials.email)); + equals(setupData.credentials.email)); // Verify tokens were updated final newAccessToken = await UserService.getAccessToken(); From 71c0ce168349b2cc0de5e5537b0a73b4804e44a4 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 13:19:49 -0600 Subject: [PATCH 13/31] Fix fill_in_the_blank test assertions - task_description is on run (after start), not specification - Add proper status code checks to second test for better debugging Co-Authored-By: Claude Opus 4.5 --- .../integration_test/fill_in_the_blank_test.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index 4786da71..6a8e1ae3 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -84,12 +84,10 @@ void main() { final runId = createRunResponse.data['data']['id']; expect(runId, isNotNull); - // Verify specification is included + // Verify specification is included (task_description is hidden until run starts) final included = createRunResponse.data['included'] as List; final specData = included.firstWhere((i) => i['type'] == 'specifications'); expect(specData['attributes']['concept'], equals('fill_in_the_blank')); - expect(specData['attributes']['task_description'], - equals('What is the answer to this test?')); // Verify answer is included (with label but not the actual answer) final answerData = included.firstWhere((i) => i['type'] == 'answers'); @@ -110,6 +108,10 @@ void main() { expect( startRunResponse.data['data']['attributes']['started_at'], isNotNull); + // Now task_description should be visible on the run + expect(startRunResponse.data['data']['attributes']['task_description'], + equals('What is the answer to this test?')); + // Step 3: Submit an incorrect answer first final wrongSubmissionResponse = await dio.post( '/waydowntown/submissions', @@ -167,21 +169,25 @@ void main() { (WidgetTester tester) async { expect(setupData.gameData, isNotNull); - // Create and start a run + // Create a run final createRunResponse = await dio.post( '/waydowntown/runs?filter[specification.concept]=fill_in_the_blank', data: { 'data': {'type': 'runs', 'attributes': {}}, }, ); + expect(createRunResponse.statusCode, equals(201)); final runId = createRunResponse.data['data']['id']; + expect(runId, isNotNull); - await dio.post( + // Start the run + final startRunResponse = await dio.post( '/waydowntown/runs/$runId/start', data: { 'data': {'type': 'runs', 'id': runId}, }, ); + expect(startRunResponse.statusCode, equals(200)); // Submit answer with different case and whitespace final submissionResponse = await dio.post( From 7560ab49a7e69c51b7e7bbb38bbdfc6c7d7e03e9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 14:59:42 -0600 Subject: [PATCH 14/31] Fix fill_in_the_blank test: add required answer_id fill_in_the_blank concept requires answer_id in submissions. Extract answer ID from run creation response and include it in all submission requests. Co-Authored-By: Claude Opus 4.5 --- .../fill_in_the_blank_test.dart | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index 6a8e1ae3..a7c74749 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -92,6 +92,7 @@ void main() { // Verify answer is included (with label but not the actual answer) final answerData = included.firstWhere((i) => i['type'] == 'answers'); expect(answerData['attributes']['label'], equals('The answer is ____')); + final answerId = answerData['id']; // Step 2: Start the run final startRunResponse = await dio.post( @@ -113,6 +114,7 @@ void main() { equals('What is the answer to this test?')); // Step 3: Submit an incorrect answer first + // Note: fill_in_the_blank requires answer_id in submission final wrongSubmissionResponse = await dio.post( '/waydowntown/submissions', data: { @@ -123,6 +125,9 @@ void main() { 'run': { 'data': {'type': 'runs', 'id': runId}, }, + 'answer': { + 'data': {'type': 'answers', 'id': answerId}, + }, }, }, }, @@ -149,6 +154,9 @@ void main() { 'run': { 'data': {'type': 'runs', 'id': runId}, }, + 'answer': { + 'data': {'type': 'answers', 'id': answerId}, + }, }, }, }, @@ -180,6 +188,11 @@ void main() { final runId = createRunResponse.data['data']['id']; expect(runId, isNotNull); + // Get answer ID from included data + final included = createRunResponse.data['included'] as List; + final answerData = included.firstWhere((i) => i['type'] == 'answers'); + final answerId = answerData['id']; + // Start the run final startRunResponse = await dio.post( '/waydowntown/runs/$runId/start', @@ -190,6 +203,7 @@ void main() { expect(startRunResponse.statusCode, equals(200)); // Submit answer with different case and whitespace + // Note: fill_in_the_blank requires answer_id in submission final submissionResponse = await dio.post( '/waydowntown/submissions', data: { @@ -200,6 +214,9 @@ void main() { 'run': { 'data': {'type': 'runs', 'id': runId}, }, + 'answer': { + 'data': {'type': 'answers', 'id': answerId}, + }, }, }, }, @@ -209,9 +226,9 @@ void main() { expect(submissionResponse.data['data']['attributes']['correct'], isTrue); // Verify run is complete - final included = submissionResponse.data['included'] as List; - final runData = - included.firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + final submissionIncluded = submissionResponse.data['included'] as List; + final runData = submissionIncluded + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); expect(runData['attributes']['complete'], isTrue); }); } From ce8c654c46270cb93203c70a6c97f09176ce6fa1 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 19:43:59 -0600 Subject: [PATCH 15/31] Fix TestController: insert Answer separately Specification has has_many relationship with Answer, not embeds_many. Must insert Answer separately with specification_id. Co-Authored-By: Claude Opus 4.5 --- .../lib/registrations_web/controllers/test_controller.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index a3eafa63..1f5413c1 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -62,12 +62,17 @@ defmodule RegistrationsWeb.TestController do Repo.insert!(%Specification{ concept: "fill_in_the_blank", task_description: "What is the answer to this test?", - answers: [%Answer{label: "The answer is ____", answer: "correct"}], region: region, duration: 300 }) - answer = List.first(specification.answers) + # Insert answer separately (has_many relationship) + answer = + Repo.insert!(%Answer{ + label: "The answer is ____", + answer: "correct", + specification_id: specification.id + }) %{ specification_id: specification.id, From 802511896ba0950e6b74068fe78afa0827c815fe Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 7 Feb 2026 20:58:24 -0600 Subject: [PATCH 16/31] Add string_collector integration test and publish test results - Add string_collector game creation to TestController - Make TestGameData flexible for different game types - Add string_collector_test.dart testing multi-item collection - Add test result publishing step to CI workflow Co-Authored-By: Claude Opus 4.5 --- .../workflows/ci-waydowntown-full-stack.yml | 9 +- .../controllers/test_controller.ex | 29 +++ .../string_collector_test.dart | 243 ++++++++++++++++++ .../integration_test/test_backend_client.dart | 19 +- 4 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 waydowntown_app/integration_test/string_collector_test.dart diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 82a9bd8f..6616a316 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,4 +93,11 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/ --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 + script: flutter test integration_test/ --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --file-reporter json:test-results.json + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + check_name: "Full-stack integration test results" + files: waydowntown_app/test-results.json diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index 1f5413c1..496a766c 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -43,6 +43,10 @@ defmodule RegistrationsWeb.TestController do game_data = create_fill_in_the_blank_game() Map.merge(base_response, game_data) + "string_collector" -> + game_data = create_string_collector_game() + Map.merge(base_response, game_data) + _ -> base_response end @@ -81,6 +85,31 @@ defmodule RegistrationsWeb.TestController do } end + defp create_string_collector_game do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "string_collector", + task_description: "Find all the hidden words", + start_description: "Look around for words", + region: region, + duration: 300 + }) + + # Insert answers separately (has_many relationship) + answer1 = Repo.insert!(%Answer{answer: "apple", specification_id: specification.id}) + answer2 = Repo.insert!(%Answer{answer: "banana", specification_id: specification.id}) + answer3 = Repo.insert!(%Answer{answer: "cherry", specification_id: specification.id}) + + %{ + specification_id: specification.id, + correct_answers: ["apple", "banana", "cherry"], + total_answers: 3, + answer_ids: [answer1.id, answer2.id, answer3.id] + } + end + defp create_or_reset_test_user do case Repo.get_by(RegistrationsWeb.User, email: @test_email) do nil -> diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart new file mode 100644 index 00000000..bccc1f2a --- /dev/null +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -0,0 +1,243 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + late TestSetupData setupData; + late TestTokens tokens; + late Dio dio; + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + + testClient = TestBackendClient(); + + // Reset database and create test user with string_collector game + final data = await testClient.resetDatabase( + createUser: true, + game: 'string_collector', + ); + setupData = data!; + + // Login to get tokens + tokens = await testClient.login( + setupData.credentials.email, + setupData.credentials.password, + ); + + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + // Create authenticated Dio instance + dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + final renewalDio = Dio(BaseOptions(baseUrl: TestConfig.apiBaseUrl)); + final postRenewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + dio.interceptors.add(RefreshTokenInterceptor( + dio: dio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + }); + + testWidgets('string_collector game: collect all items to win', + (WidgetTester tester) async { + // Verify game data + expect(setupData.gameData, isNotNull); + expect(setupData.gameData!.totalAnswers, equals(3)); + expect(setupData.gameData!.correctAnswers, + containsAll(['apple', 'banana', 'cherry'])); + + // Create a run + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=string_collector', + data: { + 'data': {'type': 'runs', 'attributes': {}}, + }, + ); + expect(createRunResponse.statusCode, equals(201)); + final runId = createRunResponse.data['data']['id']; + + // Start the run + final startRunResponse = await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': {'type': 'runs', 'id': runId}, + }, + ); + expect(startRunResponse.statusCode, equals(200)); + + // Submit first correct answer (case insensitive) + final submission1 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': ' APPLE '}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + expect(submission1.statusCode, equals(201)); + expect(submission1.data['data']['attributes']['correct'], isTrue); + + // Check progress + var runData = (submission1.data['included'] as List) + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['correct_submissions'], equals(1)); + expect(runData['attributes']['total_answers'], equals(3)); + expect(runData['attributes']['complete'], isFalse); + + // Submit incorrect answer + final wrongSubmission = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'wrong'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + expect(wrongSubmission.statusCode, equals(201)); + expect(wrongSubmission.data['data']['attributes']['correct'], isFalse); + + // Submit second correct answer + final submission2 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'banana'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + expect(submission2.statusCode, equals(201)); + expect(submission2.data['data']['attributes']['correct'], isTrue); + + runData = (submission2.data['included'] as List) + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['correct_submissions'], equals(2)); + expect(runData['attributes']['complete'], isFalse); + + // Submit third correct answer - should win + final submission3 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'Cherry'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + expect(submission3.statusCode, equals(201)); + expect(submission3.data['data']['attributes']['correct'], isTrue); + + // Verify win + runData = (submission3.data['included'] as List) + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['correct_submissions'], equals(3)); + expect(runData['attributes']['complete'], isTrue); + }); + + testWidgets('string_collector game: rejects duplicate submissions', + (WidgetTester tester) async { + expect(setupData.gameData, isNotNull); + + // Create and start a run + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=string_collector', + data: { + 'data': {'type': 'runs', 'attributes': {}}, + }, + ); + expect(createRunResponse.statusCode, equals(201)); + final runId = createRunResponse.data['data']['id']; + + await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': {'type': 'runs', 'id': runId}, + }, + ); + + // Submit first answer + final submission1 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'apple'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + expect(submission1.statusCode, equals(201)); + + // Try to submit same answer again (should be rejected) + try { + await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': ' Apple '}, // same, different case + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + }, + }, + }, + ); + fail('Expected 422 error for duplicate submission'); + } on DioException catch (e) { + expect(e.response?.statusCode, equals(422)); + expect(e.response?.data['errors'][0]['detail'], + equals('Submission already submitted')); + } + }); +} diff --git a/waydowntown_app/integration_test/test_backend_client.dart b/waydowntown_app/integration_test/test_backend_client.dart index 48e8f317..8b64effe 100644 --- a/waydowntown_app/integration_test/test_backend_client.dart +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -24,24 +24,31 @@ class TestUserCredentials { } /// Game data returned when creating test games. +/// Fields vary by game type - use the raw data map for game-specific fields. class TestGameData { final String specificationId; - final String answerId; - final String correctAnswer; + final Map raw; TestGameData({ required this.specificationId, - required this.answerId, - required this.correctAnswer, + required this.raw, }); factory TestGameData.fromJson(Map json) { return TestGameData( specificationId: json['specification_id'], - answerId: json['answer_id'], - correctAnswer: json['correct_answer'], + raw: json, ); } + + // fill_in_the_blank specific + String? get answerId => raw['answer_id']; + String? get correctAnswer => raw['correct_answer']; + + // string_collector specific + List? get correctAnswers => + (raw['correct_answers'] as List?)?.cast(); + int? get totalAnswers => raw['total_answers']; } /// Combined response from reset endpoint with user and optional game data. From 445696bae69d44ced0519a08e6e03a760bb7f2ea Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sun, 8 Feb 2026 09:40:47 -0600 Subject: [PATCH 17/31] Add orientation_memory integration test Tests the ordered sequence game type: - Complete sequence in correct order to win - Wrong order submission rejected with 422 - Wrong answer resets progress back to order 1 Co-Authored-By: Claude Opus 4.5 --- .../controllers/test_controller.ex | 29 ++ .../orientation_memory_test.dart | 367 ++++++++++++++++++ .../integration_test/test_backend_client.dart | 6 + 3 files changed, 402 insertions(+) create mode 100644 waydowntown_app/integration_test/orientation_memory_test.dart diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index 496a766c..31403d4f 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -47,6 +47,10 @@ defmodule RegistrationsWeb.TestController do game_data = create_string_collector_game() Map.merge(base_response, game_data) + "orientation_memory" -> + game_data = create_orientation_memory_game() + Map.merge(base_response, game_data) + _ -> base_response end @@ -110,6 +114,31 @@ defmodule RegistrationsWeb.TestController do } end + defp create_orientation_memory_game do + region = Repo.insert!(%Region{name: "Test Region"}) + + specification = + Repo.insert!(%Specification{ + concept: "orientation_memory", + task_description: "Remember the sequence of directions", + start_description: "Watch the pattern carefully", + region: region, + duration: 300 + }) + + # Insert ordered answers (order field is required for orientation_memory) + answer1 = Repo.insert!(%Answer{answer: "north", order: 1, specification_id: specification.id}) + answer2 = Repo.insert!(%Answer{answer: "east", order: 2, specification_id: specification.id}) + answer3 = Repo.insert!(%Answer{answer: "south", order: 3, specification_id: specification.id}) + + %{ + specification_id: specification.id, + ordered_answers: ["north", "east", "south"], + total_answers: 3, + answer_ids: [answer1.id, answer2.id, answer3.id] + } + end + defp create_or_reset_test_user do case Repo.get_by(RegistrationsWeb.User, email: @test_email) do nil -> diff --git a/waydowntown_app/integration_test/orientation_memory_test.dart b/waydowntown_app/integration_test/orientation_memory_test.dart new file mode 100644 index 00000000..1f4dfdcd --- /dev/null +++ b/waydowntown_app/integration_test/orientation_memory_test.dart @@ -0,0 +1,367 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + late TestSetupData setupData; + late TestTokens tokens; + late Dio dio; + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + + testClient = TestBackendClient(); + + // Reset database and create test user with orientation_memory game + final data = await testClient.resetDatabase( + createUser: true, + game: 'orientation_memory', + ); + setupData = data!; + + // Login to get tokens + tokens = await testClient.login( + setupData.credentials.email, + setupData.credentials.password, + ); + + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + // Create authenticated Dio instance + dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + final renewalDio = Dio(BaseOptions(baseUrl: TestConfig.apiBaseUrl)); + final postRenewalDio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, + headers: { + 'Content-Type': 'application/vnd.api+json', + 'Accept': 'application/vnd.api+json', + }, + )); + + dio.interceptors.add(RefreshTokenInterceptor( + dio: dio, + renewalDio: renewalDio, + postRenewalDio: postRenewalDio, + )); + }); + + testWidgets('orientation_memory game: complete sequence in order to win', + (WidgetTester tester) async { + // Verify game data + expect(setupData.gameData, isNotNull); + expect(setupData.gameData!.totalAnswers, equals(3)); + expect(setupData.gameData!.orderedAnswers, + equals(['north', 'east', 'south'])); + + // Create a run + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=orientation_memory', + data: { + 'data': {'type': 'runs', 'attributes': {}}, + }, + ); + expect(createRunResponse.statusCode, equals(201)); + final runId = createRunResponse.data['data']['id']; + + // Get answer IDs from included data (ordered by their order field) + final included = createRunResponse.data['included'] as List; + final answers = included + .where((i) => i['type'] == 'answers') + .toList() + ..sort((a, b) => + (a['attributes']['order'] as int) + .compareTo(b['attributes']['order'] as int)); + + final answer1Id = answers[0]['id']; // order 1: north + final answer2Id = answers[1]['id']; // order 2: east + final answer3Id = answers[2]['id']; // order 3: south + + // Start the run + final startRunResponse = await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': {'type': 'runs', 'id': runId}, + }, + ); + expect(startRunResponse.statusCode, equals(200)); + + // Submit first answer (order 1) + final submission1 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'north'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer1Id}, + }, + }, + }, + }, + ); + expect(submission1.statusCode, equals(201)); + expect(submission1.data['data']['attributes']['correct'], isTrue); + + // Check progress - should be 1/3 + var runData = (submission1.data['included'] as List) + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['correct_submissions'], equals(1)); + expect(runData['attributes']['complete'], isFalse); + + // Submit second answer (order 2) + final submission2 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'east'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer2Id}, + }, + }, + }, + }, + ); + expect(submission2.statusCode, equals(201)); + expect(submission2.data['data']['attributes']['correct'], isTrue); + + runData = (submission2.data['included'] as List) + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['correct_submissions'], equals(2)); + expect(runData['attributes']['complete'], isFalse); + + // Submit third answer (order 3) - should win + final submission3 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'south'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer3Id}, + }, + }, + }, + }, + ); + expect(submission3.statusCode, equals(201)); + expect(submission3.data['data']['attributes']['correct'], isTrue); + + // Verify win + runData = (submission3.data['included'] as List) + .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); + expect(runData['attributes']['correct_submissions'], equals(3)); + expect(runData['attributes']['complete'], isTrue); + }); + + testWidgets('orientation_memory game: wrong order submission rejected', + (WidgetTester tester) async { + expect(setupData.gameData, isNotNull); + + // Create and start a run + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=orientation_memory', + data: { + 'data': {'type': 'runs', 'attributes': {}}, + }, + ); + expect(createRunResponse.statusCode, equals(201)); + final runId = createRunResponse.data['data']['id']; + + // Get answer IDs + final included = createRunResponse.data['included'] as List; + final answers = included + .where((i) => i['type'] == 'answers') + .toList() + ..sort((a, b) => + (a['attributes']['order'] as int) + .compareTo(b['attributes']['order'] as int)); + + final answer2Id = answers[1]['id']; // order 2: east + + await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': {'type': 'runs', 'id': runId}, + }, + ); + + // Try to submit order 2 answer first (should fail - expected order 1) + try { + await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'east'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer2Id}, + }, + }, + }, + }, + ); + fail('Expected 422 error for wrong order submission'); + } on DioException catch (e) { + expect(e.response?.statusCode, equals(422)); + expect(e.response?.data['errors'][0]['detail'], + contains('Expected submission for answer of order 1')); + } + }); + + testWidgets('orientation_memory game: wrong answer resets progress', + (WidgetTester tester) async { + expect(setupData.gameData, isNotNull); + + // Create and start a run + final createRunResponse = await dio.post( + '/waydowntown/runs?filter[specification.concept]=orientation_memory', + data: { + 'data': {'type': 'runs', 'attributes': {}}, + }, + ); + expect(createRunResponse.statusCode, equals(201)); + final runId = createRunResponse.data['data']['id']; + + // Get answer IDs + final included = createRunResponse.data['included'] as List; + final answers = included + .where((i) => i['type'] == 'answers') + .toList() + ..sort((a, b) => + (a['attributes']['order'] as int) + .compareTo(b['attributes']['order'] as int)); + + final answer1Id = answers[0]['id']; // order 1: north + final answer2Id = answers[1]['id']; // order 2: east + + await dio.post( + '/waydowntown/runs/$runId/start', + data: { + 'data': {'type': 'runs', 'id': runId}, + }, + ); + + // Submit correct first answer + final submission1 = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'north'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer1Id}, + }, + }, + }, + }, + ); + expect(submission1.statusCode, equals(201)); + expect(submission1.data['data']['attributes']['correct'], isTrue); + + // Submit WRONG answer for order 2 (wrong text, right answer_id) + final wrongSubmission = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'wrong_direction'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer2Id}, + }, + }, + }, + }, + ); + expect(wrongSubmission.statusCode, equals(201)); + expect(wrongSubmission.data['data']['attributes']['correct'], isFalse); + + // Now we should be reset to order 1 + // Try to submit order 2 again - should fail because we're back at order 1 + try { + await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'east'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer2Id}, + }, + }, + }, + }, + ); + fail('Expected 422 error - should be reset to order 1'); + } on DioException catch (e) { + expect(e.response?.statusCode, equals(422)); + expect(e.response?.data['errors'][0]['detail'], + contains('Expected submission for answer of order 1')); + } + + // Submit order 1 again - should work + final submission1Again = await dio.post( + '/waydowntown/submissions', + data: { + 'data': { + 'type': 'submissions', + 'attributes': {'submission': 'north'}, + 'relationships': { + 'run': { + 'data': {'type': 'runs', 'id': runId}, + }, + 'answer': { + 'data': {'type': 'answers', 'id': answer1Id}, + }, + }, + }, + }, + ); + expect(submission1Again.statusCode, equals(201)); + expect(submission1Again.data['data']['attributes']['correct'], isTrue); + }); +} diff --git a/waydowntown_app/integration_test/test_backend_client.dart b/waydowntown_app/integration_test/test_backend_client.dart index 8b64effe..65d9aa9b 100644 --- a/waydowntown_app/integration_test/test_backend_client.dart +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -49,6 +49,12 @@ class TestGameData { List? get correctAnswers => (raw['correct_answers'] as List?)?.cast(); int? get totalAnswers => raw['total_answers']; + + // orientation_memory specific + List? get orderedAnswers => + (raw['ordered_answers'] as List?)?.cast(); + List? get answerIds => + (raw['answer_ids'] as List?)?.cast(); } /// Combined response from reset endpoint with user and optional game data. From e6b5ed7906c996304e805c829322ce0af90d5622 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sun, 8 Feb 2026 10:48:03 -0600 Subject: [PATCH 18/31] Fix orientation_memory test: use specification.id filter orientation_memory is a "placeless" concept, so filtering by concept creates a new specification with random answers. We must use the specification.id filter to use our test specification with known answers. Co-Authored-By: Claude Opus 4.5 --- .../orientation_memory_test.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/waydowntown_app/integration_test/orientation_memory_test.dart b/waydowntown_app/integration_test/orientation_memory_test.dart index 1f4dfdcd..a4f5a59f 100644 --- a/waydowntown_app/integration_test/orientation_memory_test.dart +++ b/waydowntown_app/integration_test/orientation_memory_test.dart @@ -69,9 +69,11 @@ void main() { expect(setupData.gameData!.orderedAnswers, equals(['north', 'east', 'south'])); - // Create a run + // Create a run using the specific specification ID (orientation_memory is placeless, + // so we must use specification.id filter to use our test data) + final specificationId = setupData.gameData!.specificationId; final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=orientation_memory', + '/waydowntown/runs?filter[specification.id]=$specificationId', data: { 'data': {'type': 'runs', 'attributes': {}}, }, @@ -186,9 +188,10 @@ void main() { (WidgetTester tester) async { expect(setupData.gameData, isNotNull); - // Create and start a run + // Create and start a run using specification ID (orientation_memory is placeless) + final specificationId = setupData.gameData!.specificationId; final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=orientation_memory', + '/waydowntown/runs?filter[specification.id]=$specificationId', data: { 'data': {'type': 'runs', 'attributes': {}}, }, @@ -245,9 +248,10 @@ void main() { (WidgetTester tester) async { expect(setupData.gameData, isNotNull); - // Create and start a run + // Create and start a run using specification ID (orientation_memory is placeless) + final specificationId = setupData.gameData!.specificationId; final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=orientation_memory', + '/waydowntown/runs?filter[specification.id]=$specificationId', data: { 'data': {'type': 'runs', 'attributes': {}}, }, From 9ea35b3295ff72910a9da21a04b403363ab869b8 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sun, 8 Feb 2026 13:18:09 -0600 Subject: [PATCH 19/31] Use TRUNCATE CASCADE for test database reset Simpler and more standard than individual delete_all calls. CASCADE handles foreign key dependencies automatically. Co-Authored-By: Claude Opus 4.5 --- .../controllers/test_controller.ex | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index 31403d4f..dc6f4441 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -7,25 +7,15 @@ defmodule RegistrationsWeb.TestController do alias Registrations.Repo alias Registrations.Waydowntown.Answer - alias Registrations.Waydowntown.Participation alias Registrations.Waydowntown.Region - alias Registrations.Waydowntown.Reveal - alias Registrations.Waydowntown.Run alias Registrations.Waydowntown.Specification - alias Registrations.Waydowntown.Submission @test_email "test@example.com" @test_password "TestPassword123!" def reset(conn, params) do - # Delete waydowntown tables in FK order - Repo.delete_all(Reveal) - Repo.delete_all(Submission) - Repo.delete_all(Participation) - Repo.delete_all(Run) - Repo.delete_all(Answer) - Repo.delete_all(Specification) - Repo.delete_all(Region) + # Truncate all waydowntown tables - CASCADE handles foreign key dependencies + Repo.query!("TRUNCATE waydowntown.reveals, waydowntown.submissions, waydowntown.participations, waydowntown.runs, waydowntown.answers, waydowntown.specifications, waydowntown.regions CASCADE") response = if params["create_user"] == "true" do From 97dabece57f2416e9fab72649c0b0829f394b1bf Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 12 Feb 2026 00:24:24 -0600 Subject: [PATCH 20/31] Change tests to exercise UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I’m sure they’ll be broken, but. --- .../fill_in_the_blank_test.dart | 243 ++---------- waydowntown_app/integration_test/helpers.dart | 18 + .../orientation_memory_test.dart | 371 ------------------ .../string_collector_test.dart | 254 ++---------- .../integration_test/test_backend_client.dart | 150 ++----- .../integration_test/token_refresh_test.dart | 142 +------ 6 files changed, 138 insertions(+), 1040 deletions(-) create mode 100644 waydowntown_app/integration_test/helpers.dart delete mode 100644 waydowntown_app/integration_test/orientation_memory_test.dart diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index a7c74749..3b331c06 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -1,10 +1,12 @@ -import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/app.dart'; import 'package:waydowntown/services/user_service.dart'; +import 'helpers.dart'; import 'test_backend_client.dart'; import 'test_config.dart'; @@ -12,223 +14,56 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); late TestBackendClient testClient; - late TestSetupData setupData; - late TestTokens tokens; - late Dio dio; setUp(() async { - // Initialize mock storage for tests FlutterSecureStorage.setMockInitialValues({}); - + dotenv.testLoad(fileInput: 'API_ROOT=${TestConfig.apiBaseUrl}'); testClient = TestBackendClient(); - - // Reset database and create test user with fill_in_the_blank game - final data = await testClient.resetDatabase( - createUser: true, - game: 'fill_in_the_blank', - ); - setupData = data!; - - // Login to get tokens - tokens = await testClient.login( - setupData.credentials.email, - setupData.credentials.password, - ); - - // Store tokens in secure storage - await UserService.setTokens(tokens.accessToken, tokens.renewalToken); - - // Create authenticated Dio instance - dio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - final renewalDio = Dio(BaseOptions(baseUrl: TestConfig.apiBaseUrl)); - final postRenewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - dio.interceptors.add(RefreshTokenInterceptor( - dio: dio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); }); - testWidgets('fill_in_the_blank game: correct answer wins', - (WidgetTester tester) async { - // Verify game data was created - expect(setupData.gameData, isNotNull); - expect(setupData.gameData!.correctAnswer, equals('correct')); - - // Step 1: Create a run (this also creates a participation) - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=fill_in_the_blank', - data: { - 'data': { - 'type': 'runs', - 'attributes': {}, - }, - }, + testWidgets('fill_in_the_blank: play complete game through UI', + (tester) async { + final resetData = + await testClient.resetDatabase(game: 'fill_in_the_blank'); + final email = resetData['email'] as String; + final tokens = await testClient.login( + email, + resetData['password'] as String, ); + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; - expect(runId, isNotNull); - - // Verify specification is included (task_description is hidden until run starts) - final included = createRunResponse.data['included'] as List; - final specData = included.firstWhere((i) => i['type'] == 'specifications'); - expect(specData['attributes']['concept'], equals('fill_in_the_blank')); - - // Verify answer is included (with label but not the actual answer) - final answerData = included.firstWhere((i) => i['type'] == 'answers'); - expect(answerData['attributes']['label'], equals('The answer is ____')); - final answerId = answerData['id']; - - // Step 2: Start the run - final startRunResponse = await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': { - 'type': 'runs', - 'id': runId, - }, - }, - ); - - expect(startRunResponse.statusCode, equals(200)); - expect( - startRunResponse.data['data']['attributes']['started_at'], isNotNull); - - // Now task_description should be visible on the run - expect(startRunResponse.data['data']['attributes']['task_description'], - equals('What is the answer to this test?')); - - // Step 3: Submit an incorrect answer first - // Note: fill_in_the_blank requires answer_id in submission - final wrongSubmissionResponse = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'wrong answer'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answerId}, - }, - }, - }, - }, - ); - - expect(wrongSubmissionResponse.statusCode, equals(201)); - expect( - wrongSubmissionResponse.data['data']['attributes']['correct'], isFalse); - - // Verify run is not complete yet - final wrongIncluded = wrongSubmissionResponse.data['included'] as List; - final runAfterWrong = - wrongIncluded.firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runAfterWrong['attributes']['complete'], isFalse); - - // Step 4: Submit the correct answer - final correctSubmissionResponse = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'correct'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answerId}, - }, - }, - }, - }, - ); + await tester.pumpWidget(const Waydowntown()); - expect(correctSubmissionResponse.statusCode, equals(201)); - expect(correctSubmissionResponse.data['data']['attributes']['correct'], - isTrue); + // Wait for session check to complete + await waitFor(tester, find.text(email)); - // Verify run is now complete - final correctIncluded = correctSubmissionResponse.data['included'] as List; - final runAfterCorrect = correctIncluded - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runAfterCorrect['attributes']['complete'], isTrue); - }); + // Scroll to and tap the game button on the home screen + final gameButton = find.text('Fill in the\nBlank'); + await tester.ensureVisible(gameButton); + await tester.tap(gameButton); - testWidgets('fill_in_the_blank game: case insensitive matching', - (WidgetTester tester) async { - expect(setupData.gameData, isNotNull); + // Wait for run creation and RunLaunchRoute to appear + await waitFor(tester, find.textContaining('ready')); - // Create a run - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=fill_in_the_blank', - data: { - 'data': {'type': 'runs', 'attributes': {}}, - }, - ); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; - expect(runId, isNotNull); - - // Get answer ID from included data - final included = createRunResponse.data['included'] as List; - final answerData = included.firstWhere((i) => i['type'] == 'answers'); - final answerId = answerData['id']; - - // Start the run - final startRunResponse = await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': {'type': 'runs', 'id': runId}, - }, - ); - expect(startRunResponse.statusCode, equals(200)); + // Tap the ready button to start the game + await tester.tap(find.textContaining('ready')); - // Submit answer with different case and whitespace - // Note: fill_in_the_blank requires answer_id in submission - final submissionResponse = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': ' CORRECT '}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answerId}, - }, - }, - }, - }, + // Wait for WebSocket countdown (~5s) and navigation to game widget. + // The answer label appears once we're in the game. + await waitFor( + tester, + find.text('The answer is ____'), + timeout: const Duration(seconds: 15), ); - expect(submissionResponse.statusCode, equals(201)); - expect(submissionResponse.data['data']['attributes']['correct'], isTrue); + // Submit a wrong answer + await tester.enterText(find.byType(TextFormField), 'wrong'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.text('Wrong')); - // Verify run is complete - final submissionIncluded = submissionResponse.data['included'] as List; - final runData = submissionIncluded - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['complete'], isTrue); + // Submit the correct answer + await tester.enterText(find.byType(TextFormField), 'correct'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.textContaining('Congratulations')); }); } diff --git a/waydowntown_app/integration_test/helpers.dart b/waydowntown_app/integration_test/helpers.dart new file mode 100644 index 00000000..08162f1b --- /dev/null +++ b/waydowntown_app/integration_test/helpers.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps frames until [finder] matches at least one widget, or throws on timeout. +Future waitFor( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 15), +}) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + } + throw TimeoutException( + 'Timed out waiting for $finder', timeout); +} diff --git a/waydowntown_app/integration_test/orientation_memory_test.dart b/waydowntown_app/integration_test/orientation_memory_test.dart deleted file mode 100644 index a4f5a59f..00000000 --- a/waydowntown_app/integration_test/orientation_memory_test.dart +++ /dev/null @@ -1,371 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:waydowntown/refresh_token_interceptor.dart'; -import 'package:waydowntown/services/user_service.dart'; - -import 'test_backend_client.dart'; -import 'test_config.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - late TestBackendClient testClient; - late TestSetupData setupData; - late TestTokens tokens; - late Dio dio; - - setUp(() async { - FlutterSecureStorage.setMockInitialValues({}); - - testClient = TestBackendClient(); - - // Reset database and create test user with orientation_memory game - final data = await testClient.resetDatabase( - createUser: true, - game: 'orientation_memory', - ); - setupData = data!; - - // Login to get tokens - tokens = await testClient.login( - setupData.credentials.email, - setupData.credentials.password, - ); - - await UserService.setTokens(tokens.accessToken, tokens.renewalToken); - - // Create authenticated Dio instance - dio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - final renewalDio = Dio(BaseOptions(baseUrl: TestConfig.apiBaseUrl)); - final postRenewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - dio.interceptors.add(RefreshTokenInterceptor( - dio: dio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); - }); - - testWidgets('orientation_memory game: complete sequence in order to win', - (WidgetTester tester) async { - // Verify game data - expect(setupData.gameData, isNotNull); - expect(setupData.gameData!.totalAnswers, equals(3)); - expect(setupData.gameData!.orderedAnswers, - equals(['north', 'east', 'south'])); - - // Create a run using the specific specification ID (orientation_memory is placeless, - // so we must use specification.id filter to use our test data) - final specificationId = setupData.gameData!.specificationId; - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.id]=$specificationId', - data: { - 'data': {'type': 'runs', 'attributes': {}}, - }, - ); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; - - // Get answer IDs from included data (ordered by their order field) - final included = createRunResponse.data['included'] as List; - final answers = included - .where((i) => i['type'] == 'answers') - .toList() - ..sort((a, b) => - (a['attributes']['order'] as int) - .compareTo(b['attributes']['order'] as int)); - - final answer1Id = answers[0]['id']; // order 1: north - final answer2Id = answers[1]['id']; // order 2: east - final answer3Id = answers[2]['id']; // order 3: south - - // Start the run - final startRunResponse = await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': {'type': 'runs', 'id': runId}, - }, - ); - expect(startRunResponse.statusCode, equals(200)); - - // Submit first answer (order 1) - final submission1 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'north'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer1Id}, - }, - }, - }, - }, - ); - expect(submission1.statusCode, equals(201)); - expect(submission1.data['data']['attributes']['correct'], isTrue); - - // Check progress - should be 1/3 - var runData = (submission1.data['included'] as List) - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['correct_submissions'], equals(1)); - expect(runData['attributes']['complete'], isFalse); - - // Submit second answer (order 2) - final submission2 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'east'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer2Id}, - }, - }, - }, - }, - ); - expect(submission2.statusCode, equals(201)); - expect(submission2.data['data']['attributes']['correct'], isTrue); - - runData = (submission2.data['included'] as List) - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['correct_submissions'], equals(2)); - expect(runData['attributes']['complete'], isFalse); - - // Submit third answer (order 3) - should win - final submission3 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'south'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer3Id}, - }, - }, - }, - }, - ); - expect(submission3.statusCode, equals(201)); - expect(submission3.data['data']['attributes']['correct'], isTrue); - - // Verify win - runData = (submission3.data['included'] as List) - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['correct_submissions'], equals(3)); - expect(runData['attributes']['complete'], isTrue); - }); - - testWidgets('orientation_memory game: wrong order submission rejected', - (WidgetTester tester) async { - expect(setupData.gameData, isNotNull); - - // Create and start a run using specification ID (orientation_memory is placeless) - final specificationId = setupData.gameData!.specificationId; - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.id]=$specificationId', - data: { - 'data': {'type': 'runs', 'attributes': {}}, - }, - ); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; - - // Get answer IDs - final included = createRunResponse.data['included'] as List; - final answers = included - .where((i) => i['type'] == 'answers') - .toList() - ..sort((a, b) => - (a['attributes']['order'] as int) - .compareTo(b['attributes']['order'] as int)); - - final answer2Id = answers[1]['id']; // order 2: east - - await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': {'type': 'runs', 'id': runId}, - }, - ); - - // Try to submit order 2 answer first (should fail - expected order 1) - try { - await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'east'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer2Id}, - }, - }, - }, - }, - ); - fail('Expected 422 error for wrong order submission'); - } on DioException catch (e) { - expect(e.response?.statusCode, equals(422)); - expect(e.response?.data['errors'][0]['detail'], - contains('Expected submission for answer of order 1')); - } - }); - - testWidgets('orientation_memory game: wrong answer resets progress', - (WidgetTester tester) async { - expect(setupData.gameData, isNotNull); - - // Create and start a run using specification ID (orientation_memory is placeless) - final specificationId = setupData.gameData!.specificationId; - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.id]=$specificationId', - data: { - 'data': {'type': 'runs', 'attributes': {}}, - }, - ); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; - - // Get answer IDs - final included = createRunResponse.data['included'] as List; - final answers = included - .where((i) => i['type'] == 'answers') - .toList() - ..sort((a, b) => - (a['attributes']['order'] as int) - .compareTo(b['attributes']['order'] as int)); - - final answer1Id = answers[0]['id']; // order 1: north - final answer2Id = answers[1]['id']; // order 2: east - - await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': {'type': 'runs', 'id': runId}, - }, - ); - - // Submit correct first answer - final submission1 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'north'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer1Id}, - }, - }, - }, - }, - ); - expect(submission1.statusCode, equals(201)); - expect(submission1.data['data']['attributes']['correct'], isTrue); - - // Submit WRONG answer for order 2 (wrong text, right answer_id) - final wrongSubmission = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'wrong_direction'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer2Id}, - }, - }, - }, - }, - ); - expect(wrongSubmission.statusCode, equals(201)); - expect(wrongSubmission.data['data']['attributes']['correct'], isFalse); - - // Now we should be reset to order 1 - // Try to submit order 2 again - should fail because we're back at order 1 - try { - await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'east'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer2Id}, - }, - }, - }, - }, - ); - fail('Expected 422 error - should be reset to order 1'); - } on DioException catch (e) { - expect(e.response?.statusCode, equals(422)); - expect(e.response?.data['errors'][0]['detail'], - contains('Expected submission for answer of order 1')); - } - - // Submit order 1 again - should work - final submission1Again = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'north'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - 'answer': { - 'data': {'type': 'answers', 'id': answer1Id}, - }, - }, - }, - }, - ); - expect(submission1Again.statusCode, equals(201)); - expect(submission1Again.data['data']['attributes']['correct'], isTrue); - }); -} diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index bccc1f2a..4d32eafb 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -1,10 +1,12 @@ -import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/app.dart'; import 'package:waydowntown/services/user_service.dart'; +import 'helpers.dart'; import 'test_backend_client.dart'; import 'test_config.dart'; @@ -12,232 +14,58 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); late TestBackendClient testClient; - late TestSetupData setupData; - late TestTokens tokens; - late Dio dio; setUp(() async { FlutterSecureStorage.setMockInitialValues({}); - + dotenv.testLoad(fileInput: 'API_ROOT=${TestConfig.apiBaseUrl}'); testClient = TestBackendClient(); - - // Reset database and create test user with string_collector game - final data = await testClient.resetDatabase( - createUser: true, - game: 'string_collector', - ); - setupData = data!; - - // Login to get tokens - tokens = await testClient.login( - setupData.credentials.email, - setupData.credentials.password, - ); - - await UserService.setTokens(tokens.accessToken, tokens.renewalToken); - - // Create authenticated Dio instance - dio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - final renewalDio = Dio(BaseOptions(baseUrl: TestConfig.apiBaseUrl)); - final postRenewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - dio.interceptors.add(RefreshTokenInterceptor( - dio: dio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); }); - testWidgets('string_collector game: collect all items to win', - (WidgetTester tester) async { - // Verify game data - expect(setupData.gameData, isNotNull); - expect(setupData.gameData!.totalAnswers, equals(3)); - expect(setupData.gameData!.correctAnswers, - containsAll(['apple', 'banana', 'cherry'])); - - // Create a run - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=string_collector', - data: { - 'data': {'type': 'runs', 'attributes': {}}, - }, - ); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; - - // Start the run - final startRunResponse = await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': {'type': 'runs', 'id': runId}, - }, + testWidgets('string_collector: collect all items to win', (tester) async { + final resetData = + await testClient.resetDatabase(game: 'string_collector'); + final email = resetData['email'] as String; + final tokens = await testClient.login( + email, + resetData['password'] as String, ); - expect(startRunResponse.statusCode, equals(200)); - - // Submit first correct answer (case insensitive) - final submission1 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': ' APPLE '}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - }, - }, - }, - ); - expect(submission1.statusCode, equals(201)); - expect(submission1.data['data']['attributes']['correct'], isTrue); - - // Check progress - var runData = (submission1.data['included'] as List) - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['correct_submissions'], equals(1)); - expect(runData['attributes']['total_answers'], equals(3)); - expect(runData['attributes']['complete'], isFalse); + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); - // Submit incorrect answer - final wrongSubmission = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'wrong'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - }, - }, - }, - ); - expect(wrongSubmission.statusCode, equals(201)); - expect(wrongSubmission.data['data']['attributes']['correct'], isFalse); + await tester.pumpWidget(const Waydowntown()); + await waitFor(tester, find.text(email)); - // Submit second correct answer - final submission2 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'banana'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - }, - }, - }, - ); - expect(submission2.statusCode, equals(201)); - expect(submission2.data['data']['attributes']['correct'], isTrue); + // Scroll to and tap the game button + final gameButton = find.text('String\nCollector'); + await tester.ensureVisible(gameButton); + await tester.tap(gameButton); - runData = (submission2.data['included'] as List) - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['correct_submissions'], equals(2)); - expect(runData['attributes']['complete'], isFalse); + // Wait for run creation and RunLaunchRoute + await waitFor(tester, find.textContaining('ready')); + await tester.tap(find.textContaining('ready')); - // Submit third correct answer - should win - final submission3 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'Cherry'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - }, - }, - }, + // Wait for countdown and navigation to game + await waitFor( + tester, + find.text('Enter a string!'), + timeout: const Duration(seconds: 15), ); - expect(submission3.statusCode, equals(201)); - expect(submission3.data['data']['attributes']['correct'], isTrue); - // Verify win - runData = (submission3.data['included'] as List) - .firstWhere((i) => i['type'] == 'runs' && i['id'] == runId); - expect(runData['attributes']['correct_submissions'], equals(3)); - expect(runData['attributes']['complete'], isTrue); - }); - - testWidgets('string_collector game: rejects duplicate submissions', - (WidgetTester tester) async { - expect(setupData.gameData, isNotNull); - - // Create and start a run - final createRunResponse = await dio.post( - '/waydowntown/runs?filter[specification.concept]=string_collector', - data: { - 'data': {'type': 'runs', 'attributes': {}}, - }, - ); - expect(createRunResponse.statusCode, equals(201)); - final runId = createRunResponse.data['data']['id']; + // Verify initial progress shows 0/3 + expect(find.text('0/3'), findsOneWidget); - await dio.post( - '/waydowntown/runs/$runId/start', - data: { - 'data': {'type': 'runs', 'id': runId}, - }, - ); + // Submit first item + await tester.enterText(find.byType(TextField), 'apple'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.text('1/3')); - // Submit first answer - final submission1 = await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': 'apple'}, - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - }, - }, - }, - ); - expect(submission1.statusCode, equals(201)); + // Submit second item + await tester.enterText(find.byType(TextField), 'banana'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.text('2/3')); - // Try to submit same answer again (should be rejected) - try { - await dio.post( - '/waydowntown/submissions', - data: { - 'data': { - 'type': 'submissions', - 'attributes': {'submission': ' Apple '}, // same, different case - 'relationships': { - 'run': { - 'data': {'type': 'runs', 'id': runId}, - }, - }, - }, - }, - ); - fail('Expected 422 error for duplicate submission'); - } on DioException catch (e) { - expect(e.response?.statusCode, equals(422)); - expect(e.response?.data['errors'][0]['detail'], - equals('Submission already submitted')); - } + // Submit third item - should win + await tester.enterText(find.byType(TextField), 'cherry'); + await tester.tap(find.text('Submit')); + await waitFor(tester, find.textContaining('Congratulations')); }); } diff --git a/waydowntown_app/integration_test/test_backend_client.dart b/waydowntown_app/integration_test/test_backend_client.dart index 65d9aa9b..ccc4bc9c 100644 --- a/waydowntown_app/integration_test/test_backend_client.dart +++ b/waydowntown_app/integration_test/test_backend_client.dart @@ -2,146 +2,40 @@ import 'package:dio/dio.dart'; import 'test_config.dart'; -/// Credentials for a test user returned by the backend's reset endpoint. -class TestUserCredentials { - final String userId; - final String email; - final String password; - - TestUserCredentials({ - required this.userId, - required this.email, - required this.password, - }); - - factory TestUserCredentials.fromJson(Map json) { - return TestUserCredentials( - userId: json['user_id'], - email: json['email'], - password: json['password'], - ); - } -} - -/// Game data returned when creating test games. -/// Fields vary by game type - use the raw data map for game-specific fields. -class TestGameData { - final String specificationId; - final Map raw; - - TestGameData({ - required this.specificationId, - required this.raw, - }); - - factory TestGameData.fromJson(Map json) { - return TestGameData( - specificationId: json['specification_id'], - raw: json, - ); - } - - // fill_in_the_blank specific - String? get answerId => raw['answer_id']; - String? get correctAnswer => raw['correct_answer']; - - // string_collector specific - List? get correctAnswers => - (raw['correct_answers'] as List?)?.cast(); - int? get totalAnswers => raw['total_answers']; - - // orientation_memory specific - List? get orderedAnswers => - (raw['ordered_answers'] as List?)?.cast(); - List? get answerIds => - (raw['answer_ids'] as List?)?.cast(); -} - -/// Combined response from reset endpoint with user and optional game data. -class TestSetupData { - final TestUserCredentials credentials; - final TestGameData? gameData; - - TestSetupData({ - required this.credentials, - this.gameData, - }); -} - -/// Tokens returned by the login endpoint. -class TestTokens { - final String accessToken; - final String renewalToken; - - TestTokens({ - required this.accessToken, - required this.renewalToken, - }); -} - -/// HTTP client for test setup operations against the backend. +/// HTTP client for test-only backend endpoints. class TestBackendClient { final Dio _dio; - final String baseUrl; - TestBackendClient({String? baseUrl}) - : baseUrl = baseUrl ?? TestConfig.apiBaseUrl, - _dio = Dio(BaseOptions( - baseUrl: baseUrl ?? TestConfig.apiBaseUrl, + TestBackendClient() + : _dio = Dio(BaseOptions( + baseUrl: TestConfig.apiBaseUrl, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, )); - /// Resets the database and optionally creates a test user and game. - /// Returns credentials if [createUser] is true. - /// If [game] is specified, creates test data for that game type. - Future resetDatabase({ - bool createUser = true, - String? game, - }) async { - final response = await _dio.post( - '/test/reset', - data: { - 'create_user': createUser ? 'true' : 'false', - if (game != null) 'game': game, - }, - ); - - if (createUser && response.statusCode == 200) { - final credentials = TestUserCredentials.fromJson(response.data); - TestGameData? gameData; - - if (game != null && response.data['specification_id'] != null) { - gameData = TestGameData.fromJson(response.data); - } - - return TestSetupData(credentials: credentials, gameData: gameData); - } - return null; + /// Resets the database, creates a test user, and optionally seeds game data. + Future> resetDatabase({String? game}) async { + final response = await _dio.post('/test/reset', data: { + 'create_user': 'true', + if (game != null) 'game': game, + }); + return response.data as Map; } - /// Logs in with the given credentials and returns tokens. - Future login(String email, String password) async { - final response = await _dio.post( - '/powapi/session', - data: { - 'user': { - 'email': email, - 'password': password, - }, - }, - ); - - if (response.statusCode != 200) { - throw Exception('Login failed with status ${response.statusCode}'); - } - + /// Logs in and returns access_token and renewal_token. + Future<({String accessToken, String renewalToken})> login( + String email, + String password, + ) async { + final response = await _dio.post('/powapi/session', data: { + 'user': {'email': email, 'password': password}, + }); final data = response.data['data']; - return TestTokens( - accessToken: data['access_token'], - renewalToken: data['renewal_token'], + return ( + accessToken: data['access_token'] as String, + renewalToken: data['renewal_token'] as String, ); } } diff --git a/waydowntown_app/integration_test/token_refresh_test.dart b/waydowntown_app/integration_test/token_refresh_test.dart index 9c411afa..63248571 100644 --- a/waydowntown_app/integration_test/token_refresh_test.dart +++ b/waydowntown_app/integration_test/token_refresh_test.dart @@ -1,10 +1,11 @@ -import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'package:waydowntown/refresh_token_interceptor.dart'; +import 'package:waydowntown/app.dart'; import 'package:waydowntown/services/user_service.dart'; +import 'helpers.dart'; import 'test_backend_client.dart'; import 'test_config.dart'; @@ -12,136 +13,29 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); late TestBackendClient testClient; - late TestSetupData setupData; - late TestTokens tokens; setUp(() async { - // Initialize mock storage for tests FlutterSecureStorage.setMockInitialValues({}); - + dotenv.testLoad(fileInput: 'API_ROOT=${TestConfig.apiBaseUrl}'); testClient = TestBackendClient(); - - // Reset database and create test user - final data = await testClient.resetDatabase(createUser: true); - setupData = data!; - - // Login to get tokens - tokens = await testClient.login(setupData.credentials.email, setupData.credentials.password); - - // Store tokens in secure storage - await UserService.setTokens(tokens.accessToken, tokens.renewalToken); - }); - - testWidgets('login and access protected resource', (tester) async { - // Create Dio instances matching the app.dart pattern - final dio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - final renewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - )); - - final postRenewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - dio.interceptors.add(RefreshTokenInterceptor( - dio: dio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); - - // Access a protected endpoint (session check) - final sessionDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - )); - - sessionDio.interceptors.add(RefreshTokenInterceptor( - dio: sessionDio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); - - final response = await sessionDio.get('/fixme/session'); - - expect(response.statusCode, equals(200)); - expect(response.data['data']['attributes']['email'], - equals(setupData.credentials.email)); }); - testWidgets('token refresh works with invalid access token + valid renewal token', - (tester) async { - // Invalidate the access token but keep valid renewal token - await UserService.setTokens('invalid_access_token', tokens.renewalToken); - - // Create Dio instances matching the app.dart pattern - final dio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - )); - - final renewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - )); - - final postRenewalDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - )); - - dio.interceptors.add(RefreshTokenInterceptor( - dio: dio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); - - // Access protected endpoint - should fail initially, then refresh and succeed - final sessionDio = Dio(BaseOptions( - baseUrl: TestConfig.apiBaseUrl, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - )); - - sessionDio.interceptors.add(RefreshTokenInterceptor( - dio: sessionDio, - renewalDio: renewalDio, - postRenewalDio: postRenewalDio, - )); - - final response = await sessionDio.get('/fixme/session'); + testWidgets('refreshes expired token and shows user email', (tester) async { + final resetData = await testClient.resetDatabase(); + final email = resetData['email'] as String; + final tokens = await testClient.login( + email, + resetData['password'] as String, + ); - // The interceptor should have refreshed the token and retried - expect(response.statusCode, equals(200)); - expect(response.data['data']['attributes']['email'], - equals(setupData.credentials.email)); + // Store an invalid access token with a valid renewal token. + // When SessionWidget checks the session, it will get a 401, + // the RefreshTokenInterceptor will use the renewal token to get + // a fresh access token, and retry the request. + await UserService.setTokens('expired_invalid_token', tokens.renewalToken); - // Verify tokens were updated - final newAccessToken = await UserService.getAccessToken(); - final newRenewalToken = await UserService.getRenewalToken(); + await tester.pumpWidget(const Waydowntown()); - expect(newAccessToken, isNot(equals('invalid_access_token'))); - expect(newAccessToken, isNotNull); - expect(newRenewalToken, isNotNull); + await waitFor(tester, find.text(email)); }); } From 4a47cecd40b1ef361665659488d5f0dfbf2c5cf1 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 15:22:48 -0600 Subject: [PATCH 21/31] Update iOS Podife --- waydowntown_app/ios/Podfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/waydowntown_app/ios/Podfile.lock b/waydowntown_app/ios/Podfile.lock index 613c1f10..c76f0fbd 100644 --- a/waydowntown_app/ios/Podfile.lock +++ b/waydowntown_app/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - dchs_motion_sensors (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_blue_plus (0.0.1): - Flutter @@ -59,8 +61,6 @@ PODS: - mobile_scanner (5.2.3): - Flutter - GoogleMLKit/BarcodeScanning (~> 6.0.0) - - motion_sensors (0.0.1): - - Flutter - nanopb (2.30910.0): - nanopb/decode (= 2.30910.0) - nanopb/encode (= 2.30910.0) @@ -99,6 +99,7 @@ PODS: - sqlite3/rtree DEPENDENCIES: + - dchs_motion_sensors (from `.symlinks/plugins/dchs_motion_sensors/ios`) - Flutter (from `Flutter`) - flutter_blue_plus (from `.symlinks/plugins/flutter_blue_plus/ios`) - flutter_compass (from `.symlinks/plugins/flutter_compass/ios`) @@ -107,7 +108,6 @@ DEPENDENCIES: - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) - - motion_sensors (from `.symlinks/plugins/motion_sensors/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) @@ -132,6 +132,8 @@ SPEC REPOS: - sqlite3 EXTERNAL SOURCES: + dchs_motion_sensors: + :path: ".symlinks/plugins/dchs_motion_sensors/ios" Flutter: :path: Flutter flutter_blue_plus: @@ -148,8 +150,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/ios" - motion_sensors: - :path: ".symlinks/plugins/motion_sensors/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -162,6 +162,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqlite3_flutter_libs/ios" SPEC CHECKSUMS: + dchs_motion_sensors: 9cef816635a39345cda9f0c4943e061f6429f453 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_blue_plus: 4837da7d00cf5d441fdd6635b3a57f936778ea96 flutter_compass: cbbd285cea1584c7ac9c4e0c3e1f17cbea55e855 @@ -180,7 +181,6 @@ SPEC CHECKSUMS: MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1 MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1 mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740 - motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91 nanopb: 438bc412db1928dac798aa6fd75726007be04262 package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 From 6a00ebe3ab48e179cb09fb4d1ebc2a2b4b8a5234 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 15:23:04 -0600 Subject: [PATCH 22/31] Add some test fixes --- .../lib/registrations_web/controllers/test_controller.ex | 6 +++++- .../integration_test/fill_in_the_blank_test.dart | 3 +++ waydowntown_app/integration_test/string_collector_test.dart | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index dc6f4441..73f6494e 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -11,7 +11,7 @@ defmodule RegistrationsWeb.TestController do alias Registrations.Waydowntown.Specification @test_email "test@example.com" - @test_password "TestPassword123!" + @test_password "TestPassword1234" def reset(conn, params) do # Truncate all waydowntown tables - CASCADE handles foreign key dependencies @@ -139,6 +139,8 @@ defmodule RegistrationsWeb.TestController do password_confirmation: @test_password }) |> Repo.insert!() + |> Ecto.Changeset.change(%{name: "Test User"}) + |> Repo.update!() existing_user -> # Delete and recreate to ensure clean state @@ -151,6 +153,8 @@ defmodule RegistrationsWeb.TestController do password_confirmation: @test_password }) |> Repo.insert!() + |> Ecto.Changeset.change(%{name: "Test User"}) + |> Repo.update!() end end end diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index 3b331c06..2a50fea6 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -65,5 +65,8 @@ void main() { await tester.enterText(find.byType(TextFormField), 'correct'); await tester.tap(find.text('Submit')); await waitFor(tester, find.textContaining('Congratulations')); + + // Let confetti animation timers fire while widget tree is still alive + await tester.pump(const Duration(seconds: 1)); }); } diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index 4d32eafb..a35bd429 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -67,5 +67,8 @@ void main() { await tester.enterText(find.byType(TextField), 'cherry'); await tester.tap(find.text('Submit')); await waitFor(tester, find.textContaining('Congratulations')); + + // Let confetti animation timers fire while widget tree is still alive + await tester.pump(const Duration(seconds: 1)); }); } From 59a185ca0df3cc0b869785616a6527af50f3de85 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 16:11:29 -0600 Subject: [PATCH 23/31] Disable test timeout for full-stack CI The Gradle APK build takes ~11.5 minutes on CI, which consumes most of the default 12-minute test timeout. The first test file loaded alphabetically would always time out during the build phase. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci-waydowntown-full-stack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 6616a316..8570548c 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,7 +93,7 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/ --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --file-reporter json:test-results.json + script: flutter test integration_test/ --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json - name: Publish test results uses: EnricoMi/publish-unit-test-result-action@v2 From 3fcf84b35fdacdb53ff05a5004472d1b8b622db3 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 16:40:21 -0600 Subject: [PATCH 24/31] Make game button taps more robust for CI emulator Wait for game button to appear before tapping, pumpAndSettle after ensureVisible to let scroll complete, and increase ready timeout to 30s. The first test on a fresh Android emulator can be slower. Co-Authored-By: Claude Opus 4.6 --- waydowntown_app/integration_test/fill_in_the_blank_test.dart | 5 ++++- waydowntown_app/integration_test/string_collector_test.dart | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index 2a50fea6..99172c96 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -39,11 +39,14 @@ void main() { // Scroll to and tap the game button on the home screen final gameButton = find.text('Fill in the\nBlank'); + await waitFor(tester, gameButton); await tester.ensureVisible(gameButton); + await tester.pumpAndSettle(); await tester.tap(gameButton); // Wait for run creation and RunLaunchRoute to appear - await waitFor(tester, find.textContaining('ready')); + await waitFor(tester, find.textContaining('ready'), + timeout: const Duration(seconds: 30)); // Tap the ready button to start the game await tester.tap(find.textContaining('ready')); diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index a35bd429..eb6a6bfd 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -36,11 +36,14 @@ void main() { // Scroll to and tap the game button final gameButton = find.text('String\nCollector'); + await waitFor(tester, gameButton); await tester.ensureVisible(gameButton); + await tester.pumpAndSettle(); await tester.tap(gameButton); // Wait for run creation and RunLaunchRoute - await waitFor(tester, find.textContaining('ready')); + await waitFor(tester, find.textContaining('ready'), + timeout: const Duration(seconds: 30)); await tester.tap(find.textContaining('ready')); // Wait for countdown and navigation to game From d0205d73b7841b57cad4889858dd49a38b0562eb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 17:09:22 -0600 Subject: [PATCH 25/31] Replace pumpAndSettle with fixed pump after ensureVisible pumpAndSettle waits for all animations to stop, which can hang indefinitely on the home screen (SVG logo, etc). A fixed 500ms pump gives enough time for scroll to complete without the hang risk. Co-Authored-By: Claude Opus 4.6 --- waydowntown_app/integration_test/fill_in_the_blank_test.dart | 2 +- waydowntown_app/integration_test/string_collector_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index 99172c96..2380ac19 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -41,7 +41,7 @@ void main() { final gameButton = find.text('Fill in the\nBlank'); await waitFor(tester, gameButton); await tester.ensureVisible(gameButton); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); await tester.tap(gameButton); // Wait for run creation and RunLaunchRoute to appear diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index eb6a6bfd..a015da11 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -38,7 +38,7 @@ void main() { final gameButton = find.text('String\nCollector'); await waitFor(tester, gameButton); await tester.ensureVisible(gameButton); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); await tester.tap(gameButton); // Wait for run creation and RunLaunchRoute From 4130cf2e940eff55b8356c16ca784a59170d0fc8 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 19:08:26 -0600 Subject: [PATCH 26/31] Fix CI string_collector WebSocket timeout: increase ready wait, disable origin check, control test order - Increase ready button wait timeout from 30s to 120s for game tests (WebSocket connection is slow on CI emulator without hardware acceleration) - Add check_origin: false to Phoenix test config (matches dev config) - Run tests in explicit order: token_refresh first to warm up emulator Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci-waydowntown-full-stack.yml | 2 +- registrations/config/test.exs | 3 ++- waydowntown_app/integration_test/fill_in_the_blank_test.dart | 3 ++- waydowntown_app/integration_test/string_collector_test.dart | 3 ++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 8570548c..6e69495f 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,7 +93,7 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/ --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json + script: flutter test integration_test/token_refresh_test.dart integration_test/fill_in_the_blank_test.dart integration_test/string_collector_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json - name: Publish test results uses: EnricoMi/publish-unit-test-result-action@v2 diff --git a/registrations/config/test.exs b/registrations/config/test.exs index 3128ce8f..cbedc831 100644 --- a/registrations/config/test.exs +++ b/registrations/config/test.exs @@ -27,4 +27,5 @@ config :registrations, Registrations.Repo, config :registrations, RegistrationsWeb.Endpoint, http: [ip: {0, 0, 0, 0}, port: 4001], - server: true + server: true, + check_origin: false diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index 2380ac19..aea27de3 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -45,8 +45,9 @@ void main() { await tester.tap(gameButton); // Wait for run creation and RunLaunchRoute to appear + // WebSocket connection can be slow on CI emulators without hardware acceleration await waitFor(tester, find.textContaining('ready'), - timeout: const Duration(seconds: 30)); + timeout: const Duration(seconds: 120)); // Tap the ready button to start the game await tester.tap(find.textContaining('ready')); diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index a015da11..22467041 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -42,8 +42,9 @@ void main() { await tester.tap(gameButton); // Wait for run creation and RunLaunchRoute + // WebSocket connection can be slow on CI emulators without hardware acceleration await waitFor(tester, find.textContaining('ready'), - timeout: const Duration(seconds: 30)); + timeout: const Duration(seconds: 120)); await tester.tap(find.textContaining('ready')); // Wait for countdown and navigation to game From a592e86346fb2e260eeff771de2ac81ac8e264d2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 19:37:40 -0600 Subject: [PATCH 27/31] Add WebSocket connection timeout with retry, better test diagnostics - Add 15s timeout to WebSocket connect and channel join in RunLaunchRoute - Add retry logic (3 attempts with exponential backoff) for connection failures - Add failOn parameter to waitFor helper for fast failure on error detection - Swap test order: run string_collector before fill_in_the_blank to test position dependency Co-Authored-By: Claude Opus 4.6 --- .../workflows/ci-waydowntown-full-stack.yml | 2 +- .../fill_in_the_blank_test.dart | 3 ++- waydowntown_app/integration_test/helpers.dart | 5 +++++ .../string_collector_test.dart | 3 ++- .../lib/routes/run_launch_route.dart | 21 ++++++++++++++++--- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 6e69495f..80abc7e4 100644 --- a/.github/workflows/ci-waydowntown-full-stack.yml +++ b/.github/workflows/ci-waydowntown-full-stack.yml @@ -93,7 +93,7 @@ jobs: api-level: 24 arch: x86_64 profile: Nexus 6 - script: flutter test integration_test/token_refresh_test.dart integration_test/fill_in_the_blank_test.dart integration_test/string_collector_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json + script: flutter test integration_test/token_refresh_test.dart integration_test/string_collector_test.dart integration_test/fill_in_the_blank_test.dart --flavor local --dart-define=API_BASE_URL=http://10.0.2.2:4001 --timeout none --file-reporter json:test-results.json - name: Publish test results uses: EnricoMi/publish-unit-test-result-action@v2 diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index aea27de3..f9ef8c96 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -47,7 +47,8 @@ void main() { // Wait for run creation and RunLaunchRoute to appear // WebSocket connection can be slow on CI emulators without hardware acceleration await waitFor(tester, find.textContaining('ready'), - timeout: const Duration(seconds: 120)); + timeout: const Duration(seconds: 120), + failOn: find.textContaining('Error connecting')); // Tap the ready button to start the game await tester.tap(find.textContaining('ready')); diff --git a/waydowntown_app/integration_test/helpers.dart b/waydowntown_app/integration_test/helpers.dart index 08162f1b..57aa960f 100644 --- a/waydowntown_app/integration_test/helpers.dart +++ b/waydowntown_app/integration_test/helpers.dart @@ -7,11 +7,16 @@ Future waitFor( WidgetTester tester, Finder finder, { Duration timeout = const Duration(seconds: 15), + Finder? failOn, }) async { final end = DateTime.now().add(timeout); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 100)); if (finder.evaluate().isNotEmpty) return; + if (failOn != null && failOn.evaluate().isNotEmpty) { + throw TestFailure( + 'Found fail-on widget while waiting for $finder: $failOn'); + } } throw TimeoutException( 'Timed out waiting for $finder', timeout); diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index 22467041..5fdce37d 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -44,7 +44,8 @@ void main() { // Wait for run creation and RunLaunchRoute // WebSocket connection can be slow on CI emulators without hardware acceleration await waitFor(tester, find.textContaining('ready'), - timeout: const Duration(seconds: 120)); + timeout: const Duration(seconds: 120), + failOn: find.textContaining('Error connecting')); await tester.tap(find.textContaining('ready')); // Wait for countdown and navigation to game diff --git a/waydowntown_app/lib/routes/run_launch_route.dart b/waydowntown_app/lib/routes/run_launch_route.dart index d5d62fca..f23e9ed3 100644 --- a/waydowntown_app/lib/routes/run_launch_route.dart +++ b/waydowntown_app/lib/routes/run_launch_route.dart @@ -82,7 +82,16 @@ class _RunLaunchRouteState extends State { Future _initializeConnection() async { _currentUserId = await UserService.getUserId(); - await _connectToSocket(); + // Retry connection up to 3 times to handle transient failures + for (var attempt = 1; attempt <= 3; attempt++) { + try { + await _connectToSocket(); + return; + } catch (e) { + if (attempt == 3) rethrow; + await Future.delayed(Duration(seconds: attempt * 2)); + } + } } Future _connectToSocket() async { @@ -96,10 +105,16 @@ class _RunLaunchRouteState extends State { PhoenixSocket('$apiRoot/socket/websocket', socketOptions: socketOptions); - await socket!.connect(); + await socket!.connect().timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException('WebSocket connection timed out'), + ); channel = socket!.addChannel(topic: 'run:${widget.run.id}'); - await channel!.join().future; + await channel!.join().future.timeout( + const Duration(seconds: 15), + onTimeout: () => throw TimeoutException('Channel join timed out'), + ); channel!.messages.listen((message) { if (message.event == const PhoenixChannelEvent.custom('run_update')) { From 982481f5800dd88f6a793baa1c3e2c96daa113b4 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 20:11:42 -0600 Subject: [PATCH 28/31] Fix FutureBuilder anti-pattern: cache _gameInfoFuture in didChangeDependencies _loadGameInfo(context) was called directly in build(), creating a new Future on every rebuild. FutureBuilder restarts when it sees a new Future instance, causing the inner FutureBuilder to never settle to "done" state on slow emulators where pump() triggers frequent rebuilds. Co-Authored-By: Claude Opus 4.6 --- waydowntown_app/lib/routes/run_launch_route.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/waydowntown_app/lib/routes/run_launch_route.dart b/waydowntown_app/lib/routes/run_launch_route.dart index f23e9ed3..c2bbe993 100644 --- a/waydowntown_app/lib/routes/run_launch_route.dart +++ b/waydowntown_app/lib/routes/run_launch_route.dart @@ -44,6 +44,7 @@ class _RunLaunchRouteState extends State { DateTime? startTime; Timer? countdownTimer; late Future connectionFuture; + Future>? _gameInfoFuture; String? _currentUserId; List get opponents { @@ -80,6 +81,12 @@ class _RunLaunchRouteState extends State { connectionFuture = _initializeConnection(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _gameInfoFuture ??= _loadGameInfo(context); + } + Future _initializeConnection() async { _currentUserId = await UserService.getUserId(); // Retry connection up to 3 times to handle transient failures @@ -200,7 +207,7 @@ class _RunLaunchRouteState extends State { } return FutureBuilder>( - future: _loadGameInfo(context), + future: _gameInfoFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( From 2b702f0856b59a003a613539c68b2dbd3581bb55 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 16 Feb 2026 22:10:32 -0600 Subject: [PATCH 29/31] Add diagnostics for string_collector CI failure Print connection lifecycle, FutureBuilder state, and visible text widgets to understand what's on screen when the test times out. Also broaden failOn to catch any "Error" text, not just "Error connecting". Co-Authored-By: Claude Opus 4.6 --- waydowntown_app/integration_test/helpers.dart | 19 +++++++++++++++++++ .../string_collector_test.dart | 2 +- .../lib/routes/request_run_route.dart | 15 ++++++++++++--- .../lib/routes/run_launch_route.dart | 12 ++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/waydowntown_app/integration_test/helpers.dart b/waydowntown_app/integration_test/helpers.dart index 57aa960f..48f5f1f8 100644 --- a/waydowntown_app/integration_test/helpers.dart +++ b/waydowntown_app/integration_test/helpers.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; /// Pumps frames until [finder] matches at least one widget, or throws on timeout. @@ -10,6 +11,7 @@ Future waitFor( Finder? failOn, }) async { final end = DateTime.now().add(timeout); + var lastDump = DateTime.now(); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 100)); if (finder.evaluate().isNotEmpty) return; @@ -17,7 +19,24 @@ Future waitFor( throw TestFailure( 'Found fail-on widget while waiting for $finder: $failOn'); } + // Periodically log all visible text for diagnostics + if (DateTime.now().difference(lastDump).inSeconds >= 10) { + lastDump = DateTime.now(); + final texts = []; + for (final element in find.byType(Text).evaluate()) { + final widget = element.widget as Text; + if (widget.data != null) texts.add(widget.data!); + } + print('waitFor diagnostic (waiting for $finder): visible texts = $texts'); + } + } + // Final dump on timeout + final texts = []; + for (final element in find.byType(Text).evaluate()) { + final widget = element.widget as Text; + if (widget.data != null) texts.add(widget.data!); } + print('waitFor TIMEOUT: all visible texts = $texts'); throw TimeoutException( 'Timed out waiting for $finder', timeout); } diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index 5fdce37d..6cdedcf1 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -45,7 +45,7 @@ void main() { // WebSocket connection can be slow on CI emulators without hardware acceleration await waitFor(tester, find.textContaining('ready'), timeout: const Duration(seconds: 120), - failOn: find.textContaining('Error connecting')); + failOn: find.textContaining('Error')); await tester.tap(find.textContaining('ready')); // Wait for countdown and navigation to game diff --git a/waydowntown_app/lib/routes/request_run_route.dart b/waydowntown_app/lib/routes/request_run_route.dart index 0a98851d..2e0f76d2 100644 --- a/waydowntown_app/lib/routes/request_run_route.dart +++ b/waydowntown_app/lib/routes/request_run_route.dart @@ -92,9 +92,17 @@ class RequestRunRouteState extends State { queryParameters: queryParameters); if (response.statusCode == 201) { - setState(() { - run = Run.fromJson(response.data); - }); + print('RequestRunRoute: POST returned 201, parsing run...'); + try { + final parsedRun = Run.fromJson(response.data); + print('RequestRunRoute: Run parsed successfully, id=${parsedRun.id}, concept=${parsedRun.specification.concept}'); + setState(() { + run = parsedRun; + }); + } catch (parseError) { + print('RequestRunRoute: Run.fromJson FAILED: $parseError'); + rethrow; + } } else { throw Exception('Failed to load run'); } @@ -105,6 +113,7 @@ class RequestRunRouteState extends State { isRequestError = true; }); } + print('RequestRunRoute: ERROR fetching/parsing run: $error'); talker.error('Error fetching run from $endpoint: $error'); } } diff --git a/waydowntown_app/lib/routes/run_launch_route.dart b/waydowntown_app/lib/routes/run_launch_route.dart index c2bbe993..a2ba0139 100644 --- a/waydowntown_app/lib/routes/run_launch_route.dart +++ b/waydowntown_app/lib/routes/run_launch_route.dart @@ -88,13 +88,18 @@ class _RunLaunchRouteState extends State { } Future _initializeConnection() async { + print('RunLaunchRoute: _initializeConnection starting for run ${widget.run.id}'); _currentUserId = await UserService.getUserId(); + print('RunLaunchRoute: got userId=$_currentUserId'); // Retry connection up to 3 times to handle transient failures for (var attempt = 1; attempt <= 3; attempt++) { try { + print('RunLaunchRoute: connection attempt $attempt/3'); await _connectToSocket(); + print('RunLaunchRoute: connection attempt $attempt succeeded'); return; } catch (e) { + print('RunLaunchRoute: connection attempt $attempt failed: $e'); if (attempt == 3) rethrow; await Future.delayed(Duration(seconds: attempt * 2)); } @@ -104,6 +109,7 @@ class _RunLaunchRouteState extends State { Future _connectToSocket() async { final apiRoot = dotenv.env['API_ROOT']!.replaceFirst('http', 'ws'); final userToken = await UserService.getAccessToken(); + print('RunLaunchRoute: _connectToSocket apiRoot=$apiRoot, hasToken=${userToken != null}'); final socketOptions = PhoenixSocketOptions(params: {'Authorization': userToken!}); @@ -112,16 +118,20 @@ class _RunLaunchRouteState extends State { PhoenixSocket('$apiRoot/socket/websocket', socketOptions: socketOptions); + print('RunLaunchRoute: calling socket.connect()...'); await socket!.connect().timeout( const Duration(seconds: 15), onTimeout: () => throw TimeoutException('WebSocket connection timed out'), ); + print('RunLaunchRoute: socket.connect() completed'); channel = socket!.addChannel(topic: 'run:${widget.run.id}'); + print('RunLaunchRoute: calling channel.join() for topic run:${widget.run.id}...'); await channel!.join().future.timeout( const Duration(seconds: 15), onTimeout: () => throw TimeoutException('Channel join timed out'), ); + print('RunLaunchRoute: channel.join() completed'); channel!.messages.listen((message) { if (message.event == const PhoenixChannelEvent.custom('run_update')) { @@ -191,6 +201,7 @@ class _RunLaunchRouteState extends State { return FutureBuilder( future: connectionFuture, builder: (context, snapshot) { + print('RunLaunchRoute build: connectionFuture state=${snapshot.connectionState}, hasError=${snapshot.hasError}, error=${snapshot.error}'); if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( body: Center(child: CircularProgressIndicator()), @@ -209,6 +220,7 @@ class _RunLaunchRouteState extends State { return FutureBuilder>( future: _gameInfoFuture, builder: (context, snapshot) { + print('RunLaunchRoute build: gameInfoFuture state=${snapshot.connectionState}, hasError=${snapshot.hasError}, data=${snapshot.data}'); if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( body: Center(child: CircularProgressIndicator()), From 63a97d853c4f4456c12ed42db4787c6b72520b4a Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sun, 22 Feb 2026 13:29:39 -0700 Subject: [PATCH 30/31] Fix ready button not found: scroll ListView on small screens The "I'm ready" button was below the ListView viewport on the Nexus 6 CI emulator because string_collector renders extra cards (Starting point, Goal) that push it off-screen. ListView uses lazy rendering so the button widget didn't exist in the tree at all. Fix: wait for the AppBar title (confirming the launch screen loaded), then use scrollUntilVisible to scroll until the button appears. Also increase game navigation timeout to 30s for both tests. Co-Authored-By: Claude Opus 4.6 --- .../integration_test/fill_in_the_blank_test.dart | 14 +++++++++++--- .../integration_test/string_collector_test.dart | 15 ++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/waydowntown_app/integration_test/fill_in_the_blank_test.dart b/waydowntown_app/integration_test/fill_in_the_blank_test.dart index f9ef8c96..df7f6569 100644 --- a/waydowntown_app/integration_test/fill_in_the_blank_test.dart +++ b/waydowntown_app/integration_test/fill_in_the_blank_test.dart @@ -46,9 +46,17 @@ void main() { // Wait for run creation and RunLaunchRoute to appear // WebSocket connection can be slow on CI emulators without hardware acceleration - await waitFor(tester, find.textContaining('ready'), + await waitFor(tester, find.text('Fill in the Blank'), timeout: const Duration(seconds: 120), - failOn: find.textContaining('Error connecting')); + failOn: find.textContaining('Error')); + + // The ready button may be below the viewport on small screens. + // Use scrollUntilVisible to scroll the ListView until the button appears. + await tester.scrollUntilVisible( + find.textContaining('ready'), + 200.0, + scrollable: find.byType(Scrollable).last, + ); // Tap the ready button to start the game await tester.tap(find.textContaining('ready')); @@ -58,7 +66,7 @@ void main() { await waitFor( tester, find.text('The answer is ____'), - timeout: const Duration(seconds: 15), + timeout: const Duration(seconds: 30), ); // Submit a wrong answer diff --git a/waydowntown_app/integration_test/string_collector_test.dart b/waydowntown_app/integration_test/string_collector_test.dart index 6cdedcf1..0630c1a1 100644 --- a/waydowntown_app/integration_test/string_collector_test.dart +++ b/waydowntown_app/integration_test/string_collector_test.dart @@ -41,18 +41,27 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); await tester.tap(gameButton); - // Wait for run creation and RunLaunchRoute + // Wait for run creation and RunLaunchRoute to appear // WebSocket connection can be slow on CI emulators without hardware acceleration - await waitFor(tester, find.textContaining('ready'), + await waitFor(tester, find.text('String Collector'), timeout: const Duration(seconds: 120), failOn: find.textContaining('Error')); + + // The ready button may be below the viewport on small screens due to + // Instructions, Players, Starting point, Goal, Duration, and Location cards. + // Use scrollUntilVisible to scroll the ListView until the button appears. + await tester.scrollUntilVisible( + find.textContaining('ready'), + 200.0, + scrollable: find.byType(Scrollable).last, + ); await tester.tap(find.textContaining('ready')); // Wait for countdown and navigation to game await waitFor( tester, find.text('Enter a string!'), - timeout: const Duration(seconds: 15), + timeout: const Duration(seconds: 30), ); // Verify initial progress shows 0/3 From cf57c3d4af09edd1d79100a085f79a9302af0a03 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 23 Feb 2026 21:03:14 -0700 Subject: [PATCH 31/31] Remove diagnostic prints, keep timeout text dump in waitFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI confirmed the scroll fix works — all 3 tests pass. Remove verbose print statements from RunLaunchRoute and RequestRunRoute. Keep the widget text dump on waitFor timeout as it's useful for future debugging. Co-Authored-By: Claude Opus 4.6 --- waydowntown_app/integration_test/helpers.dart | 16 ++-------------- .../lib/routes/request_run_route.dart | 15 +++------------ waydowntown_app/lib/routes/run_launch_route.dart | 12 ------------ 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/waydowntown_app/integration_test/helpers.dart b/waydowntown_app/integration_test/helpers.dart index 48f5f1f8..a26889dc 100644 --- a/waydowntown_app/integration_test/helpers.dart +++ b/waydowntown_app/integration_test/helpers.dart @@ -11,7 +11,6 @@ Future waitFor( Finder? failOn, }) async { final end = DateTime.now().add(timeout); - var lastDump = DateTime.now(); while (DateTime.now().isBefore(end)) { await tester.pump(const Duration(milliseconds: 100)); if (finder.evaluate().isNotEmpty) return; @@ -19,24 +18,13 @@ Future waitFor( throw TestFailure( 'Found fail-on widget while waiting for $finder: $failOn'); } - // Periodically log all visible text for diagnostics - if (DateTime.now().difference(lastDump).inSeconds >= 10) { - lastDump = DateTime.now(); - final texts = []; - for (final element in find.byType(Text).evaluate()) { - final widget = element.widget as Text; - if (widget.data != null) texts.add(widget.data!); - } - print('waitFor diagnostic (waiting for $finder): visible texts = $texts'); - } } - // Final dump on timeout + // Dump all visible text on timeout for diagnostics final texts = []; for (final element in find.byType(Text).evaluate()) { final widget = element.widget as Text; if (widget.data != null) texts.add(widget.data!); } print('waitFor TIMEOUT: all visible texts = $texts'); - throw TimeoutException( - 'Timed out waiting for $finder', timeout); + throw TimeoutException('Timed out waiting for $finder', timeout); } diff --git a/waydowntown_app/lib/routes/request_run_route.dart b/waydowntown_app/lib/routes/request_run_route.dart index 2e0f76d2..0a98851d 100644 --- a/waydowntown_app/lib/routes/request_run_route.dart +++ b/waydowntown_app/lib/routes/request_run_route.dart @@ -92,17 +92,9 @@ class RequestRunRouteState extends State { queryParameters: queryParameters); if (response.statusCode == 201) { - print('RequestRunRoute: POST returned 201, parsing run...'); - try { - final parsedRun = Run.fromJson(response.data); - print('RequestRunRoute: Run parsed successfully, id=${parsedRun.id}, concept=${parsedRun.specification.concept}'); - setState(() { - run = parsedRun; - }); - } catch (parseError) { - print('RequestRunRoute: Run.fromJson FAILED: $parseError'); - rethrow; - } + setState(() { + run = Run.fromJson(response.data); + }); } else { throw Exception('Failed to load run'); } @@ -113,7 +105,6 @@ class RequestRunRouteState extends State { isRequestError = true; }); } - print('RequestRunRoute: ERROR fetching/parsing run: $error'); talker.error('Error fetching run from $endpoint: $error'); } } diff --git a/waydowntown_app/lib/routes/run_launch_route.dart b/waydowntown_app/lib/routes/run_launch_route.dart index a2ba0139..c2bbe993 100644 --- a/waydowntown_app/lib/routes/run_launch_route.dart +++ b/waydowntown_app/lib/routes/run_launch_route.dart @@ -88,18 +88,13 @@ class _RunLaunchRouteState extends State { } Future _initializeConnection() async { - print('RunLaunchRoute: _initializeConnection starting for run ${widget.run.id}'); _currentUserId = await UserService.getUserId(); - print('RunLaunchRoute: got userId=$_currentUserId'); // Retry connection up to 3 times to handle transient failures for (var attempt = 1; attempt <= 3; attempt++) { try { - print('RunLaunchRoute: connection attempt $attempt/3'); await _connectToSocket(); - print('RunLaunchRoute: connection attempt $attempt succeeded'); return; } catch (e) { - print('RunLaunchRoute: connection attempt $attempt failed: $e'); if (attempt == 3) rethrow; await Future.delayed(Duration(seconds: attempt * 2)); } @@ -109,7 +104,6 @@ class _RunLaunchRouteState extends State { Future _connectToSocket() async { final apiRoot = dotenv.env['API_ROOT']!.replaceFirst('http', 'ws'); final userToken = await UserService.getAccessToken(); - print('RunLaunchRoute: _connectToSocket apiRoot=$apiRoot, hasToken=${userToken != null}'); final socketOptions = PhoenixSocketOptions(params: {'Authorization': userToken!}); @@ -118,20 +112,16 @@ class _RunLaunchRouteState extends State { PhoenixSocket('$apiRoot/socket/websocket', socketOptions: socketOptions); - print('RunLaunchRoute: calling socket.connect()...'); await socket!.connect().timeout( const Duration(seconds: 15), onTimeout: () => throw TimeoutException('WebSocket connection timed out'), ); - print('RunLaunchRoute: socket.connect() completed'); channel = socket!.addChannel(topic: 'run:${widget.run.id}'); - print('RunLaunchRoute: calling channel.join() for topic run:${widget.run.id}...'); await channel!.join().future.timeout( const Duration(seconds: 15), onTimeout: () => throw TimeoutException('Channel join timed out'), ); - print('RunLaunchRoute: channel.join() completed'); channel!.messages.listen((message) { if (message.event == const PhoenixChannelEvent.custom('run_update')) { @@ -201,7 +191,6 @@ class _RunLaunchRouteState extends State { return FutureBuilder( future: connectionFuture, builder: (context, snapshot) { - print('RunLaunchRoute build: connectionFuture state=${snapshot.connectionState}, hasError=${snapshot.hasError}, error=${snapshot.error}'); if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( body: Center(child: CircularProgressIndicator()), @@ -220,7 +209,6 @@ class _RunLaunchRouteState extends State { return FutureBuilder>( future: _gameInfoFuture, builder: (context, snapshot) { - print('RunLaunchRoute build: gameInfoFuture state=${snapshot.connectionState}, hasError=${snapshot.hasError}, data=${snapshot.data}'); if (snapshot.connectionState == ConnectionState.waiting) { return const Scaffold( body: Center(child: CircularProgressIndicator()),