diff --git a/.github/workflows/ci-waydowntown-full-stack.yml b/.github/workflows/ci-waydowntown-full-stack.yml index 97d88608..0f275dcd 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/string_collector_test.dart integration_test/fill_in_the_blank_test.dart integration_test/team_game_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 integration_test/team_game_test.dart integration_test/edit_specification_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/lib/registrations/waydowntown.ex b/registrations/lib/registrations/waydowntown.ex index 49532ada..49debccb 100644 --- a/registrations/lib/registrations/waydowntown.ex +++ b/registrations/lib/registrations/waydowntown.ex @@ -223,7 +223,7 @@ defmodule Registrations.Waydowntown do answers = generate_answers(concept_data) with {:ok, specification} <- - create_specification(%{ + insert_specification(%{ concept: concept_key, task_description: concept_data["instructions"] }), @@ -268,7 +268,7 @@ defmodule Registrations.Waydowntown do end end - defp create_specification(attrs) do + defp insert_specification(attrs) do %Specification{} |> Specification.changeset(attrs) |> Repo.insert() @@ -348,8 +348,45 @@ defmodule Registrations.Waydowntown do |> Repo.preload(answers: [:reveals, :region], region: [parent: [parent: [:parent]]]) end + def get_answer!(id), do: Repo.get!(Answer, id) |> Repo.preload(:specification) + + def create_answer(attrs) do + %Answer{} + |> Answer.changeset(attrs) + |> Repo.insert() + end + + def update_answer(%Answer{} = answer, attrs) do + answer + |> Answer.changeset(attrs) + |> Repo.update() + end + + def delete_answer(%Answer{} = answer) do + Repo.delete(answer) + end + + def get_next_answer_order(specification_id) do + query = from(a in Answer, where: a.specification_id == ^specification_id, select: max(a.order)) + + case Repo.one(query) do + nil -> 1 + max_order -> max_order + 1 + end + end + def get_specification!(id), do: Repo.get!(Specification, id) + def create_specification(attrs) do + %Specification{} + |> Specification.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, spec} -> {:ok, Repo.preload(spec, answers: [:reveals, :region], region: [parent: [parent: [:parent]]])} + error -> error + end + end + def update_specification(%Specification{} = specification, attrs) do specification |> Specification.changeset(attrs) diff --git a/registrations/lib/registrations/waydowntown/answer.ex b/registrations/lib/registrations/waydowntown/answer.ex index 72210e21..474180dd 100644 --- a/registrations/lib/registrations/waydowntown/answer.ex +++ b/registrations/lib/registrations/waydowntown/answer.ex @@ -21,12 +21,42 @@ defmodule Registrations.Waydowntown.Answer do timestamps() end + @ordered_concepts ~w(orientation_memory cardinal_memory) + @doc false def changeset(answer, attrs) do - answer - |> cast(attrs, [:answer, :order, :specification_id, :region_id]) - |> validate_required([:answer, :order, :specification_id]) - |> assoc_constraint(:specification) - |> assoc_constraint(:region) + changeset = + answer + |> cast(attrs, [:answer, :order, :specification_id, :region_id, :label, :hint]) + |> validate_required([:answer, :specification_id]) + |> assoc_constraint(:specification) + |> assoc_constraint(:region) + + if requires_order?(answer, changeset) do + validate_required(changeset, [:order]) + else + changeset + end + end + + defp requires_order?(answer, changeset) do + concept = + cond do + answer.specification && answer.specification.__struct__ != Ecto.Association.NotLoaded -> + answer.specification.concept + + true -> + spec_id = get_field(changeset, :specification_id) + + if spec_id do + Registrations.Repo.get(Registrations.Waydowntown.Specification, spec_id) + |> case do + nil -> nil + spec -> spec.concept + end + end + end + + concept in @ordered_concepts end end diff --git a/registrations/lib/registrations_web/controllers/answer_controller.ex b/registrations/lib/registrations_web/controllers/answer_controller.ex new file mode 100644 index 00000000..047d812b --- /dev/null +++ b/registrations/lib/registrations_web/controllers/answer_controller.ex @@ -0,0 +1,64 @@ +defmodule RegistrationsWeb.AnswerController do + use RegistrationsWeb, :controller + + alias Registrations.Waydowntown + alias Registrations.Waydowntown.Answer + alias RegistrationsWeb.Owner.AnswerView + + action_fallback(RegistrationsWeb.FallbackController) + + def create(conn, %{"specification_id" => specification_id} = params) do + user = Pow.Plug.current_user(conn) + specification = Waydowntown.get_specification!(specification_id) + + if specification.creator_id == user.id do + order = Waydowntown.get_next_answer_order(specification_id) + params = Map.put(params, "order", order) + + with {:ok, %Answer{} = answer} <- Waydowntown.create_answer(params) do + answer = Waydowntown.get_answer!(answer.id) + + conn + |> put_status(:created) + |> put_view(AnswerView) + |> render("show.json", %{data: answer, conn: conn}) + end + else + conn + |> put_status(:unauthorized) + |> json(%{errors: [%{detail: "Unauthorized"}]}) + end + end + + def update(conn, %{"id" => id} = params) do + user = Pow.Plug.current_user(conn) + answer = Waydowntown.get_answer!(id) + + if answer.specification.creator_id == user.id do + with {:ok, %Answer{} = updated_answer} <- Waydowntown.update_answer(answer, params) do + conn + |> put_view(AnswerView) + |> render("show.json", %{data: updated_answer, conn: conn}) + end + else + conn + |> put_status(:unauthorized) + |> json(%{errors: [%{detail: "Unauthorized"}]}) + end + end + + def delete(conn, %{"id" => id}) do + user = Pow.Plug.current_user(conn) + answer = Waydowntown.get_answer!(id) + + if answer.specification.creator_id == user.id do + with {:ok, %Answer{}} <- Waydowntown.delete_answer(answer) do + send_resp(conn, :no_content, "") + end + else + conn + |> put_status(:unauthorized) + |> json(%{errors: [%{detail: "Unauthorized"}]}) + end + end +end diff --git a/registrations/lib/registrations_web/controllers/specification_controller.ex b/registrations/lib/registrations_web/controllers/specification_controller.ex index 737af421..3246bb68 100644 --- a/registrations/lib/registrations_web/controllers/specification_controller.ex +++ b/registrations/lib/registrations_web/controllers/specification_controller.ex @@ -20,6 +20,35 @@ defmodule RegistrationsWeb.SpecificationController do |> render("index.json", %{data: specifications, conn: conn, params: params}) end + def create(conn, params) do + user = Pow.Plug.current_user(conn) + + attrs = Map.put(params, "creator_id", user.id) + + case Waydowntown.create_specification(attrs) do + {:ok, specification} -> + conn + |> put_status(:created) + |> put_view(SpecificationView) + |> render("show.json", data: specification, conn: conn, params: params) + + {:error, changeset} -> + errors = Ecto.Changeset.traverse_errors(changeset, &RegistrationsWeb.ErrorHelpers.translate_error/1) + + conn + |> put_status(:unprocessable_entity) + |> json(%{ + errors: + Enum.map(errors, fn {field, message} -> + %{ + detail: "#{message}", + source: %{pointer: "/data/attributes/#{field}"} + } + end) + }) + end + end + def update(conn, %{"id" => id} = params) do user = Pow.Plug.current_user(conn) specification = Waydowntown.get_specification!(id) diff --git a/registrations/lib/registrations_web/controllers/test_controller.ex b/registrations/lib/registrations_web/controllers/test_controller.ex index fd7e89ae..21c529f8 100644 --- a/registrations/lib/registrations_web/controllers/test_controller.ex +++ b/registrations/lib/registrations_web/controllers/test_controller.ex @@ -30,19 +30,19 @@ defmodule RegistrationsWeb.TestController do # Optionally create game data based on concept parameter case params["game"] do "fill_in_the_blank" -> - game_data = create_fill_in_the_blank_game() + game_data = create_fill_in_the_blank_game(user) Map.merge(base_response, game_data) "string_collector" -> - game_data = create_string_collector_game() + game_data = create_string_collector_game(user) Map.merge(base_response, game_data) "orientation_memory" -> - game_data = create_orientation_memory_game() + game_data = create_orientation_memory_game(user) Map.merge(base_response, game_data) "string_collector_team" -> - game_data = create_string_collector_team_game() + game_data = create_string_collector_team_game(user) Map.merge(base_response, game_data) _ -> @@ -57,7 +57,7 @@ defmodule RegistrationsWeb.TestController do |> json(response) end - defp create_fill_in_the_blank_game do + defp create_fill_in_the_blank_game(user) do region = Repo.insert!(%Region{name: "Test Region"}) specification = @@ -65,7 +65,8 @@ defmodule RegistrationsWeb.TestController do concept: "fill_in_the_blank", task_description: "What is the answer to this test?", region: region, - duration: 300 + duration: 300, + creator_id: user.id }) # Insert answer separately (has_many relationship) @@ -83,7 +84,7 @@ defmodule RegistrationsWeb.TestController do } end - defp create_string_collector_game do + defp create_string_collector_game(user) do region = Repo.insert!(%Region{name: "Test Region"}) specification = @@ -92,7 +93,8 @@ defmodule RegistrationsWeb.TestController do task_description: "Find all the hidden words", start_description: "Look around for words", region: region, - duration: 300 + duration: 300, + creator_id: user.id }) # Insert answers separately (has_many relationship) @@ -108,7 +110,7 @@ defmodule RegistrationsWeb.TestController do } end - defp create_orientation_memory_game do + defp create_orientation_memory_game(user) do region = Repo.insert!(%Region{name: "Test Region"}) specification = @@ -117,7 +119,8 @@ defmodule RegistrationsWeb.TestController do task_description: "Remember the sequence of directions", start_description: "Watch the pattern carefully", region: region, - duration: 300 + duration: 300, + creator_id: user.id }) # Insert ordered answers (order field is required for orientation_memory) @@ -133,7 +136,7 @@ defmodule RegistrationsWeb.TestController do } end - defp create_string_collector_team_game do + defp create_string_collector_team_game(user) do region = Repo.insert!(%Region{name: "Test Region"}) specification = @@ -142,7 +145,8 @@ defmodule RegistrationsWeb.TestController do task_description: "Find all the hidden words", start_description: "Look around for words", region: region, - duration: 300 + duration: 300, + creator_id: user.id }) answer1 = Repo.insert!(%Answer{answer: "apple", specification_id: specification.id}) diff --git a/registrations/lib/registrations_web/router.ex b/registrations/lib/registrations_web/router.ex index 95137d63..cae4fcd4 100644 --- a/registrations/lib/registrations_web/router.ex +++ b/registrations/lib/registrations_web/router.ex @@ -158,7 +158,9 @@ defmodule RegistrationsWeb.Router do post "/start", RunController, :start, as: :start end - resources("/specifications", SpecificationController, only: [:update]) + resources("/answers", AnswerController, only: [:create, :update, :delete]) + + resources("/specifications", SpecificationController, only: [:create, :update]) get("/specifications/mine", SpecificationController, :mine, as: :my_specifications) resources("/submissions", SubmissionController, only: [:create, :show]) diff --git a/registrations/test/registrations_web/controllers/answer_controller_test.exs b/registrations/test/registrations_web/controllers/answer_controller_test.exs new file mode 100644 index 00000000..b0fa2c63 --- /dev/null +++ b/registrations/test/registrations_web/controllers/answer_controller_test.exs @@ -0,0 +1,253 @@ +defmodule RegistrationsWeb.AnswerControllerTest do + use RegistrationsWeb.ConnCase + + alias Registrations.Waydowntown.Answer + alias Registrations.Waydowntown.Specification + + setup %{conn: conn} do + user = insert(:octavia) + other_user = insert(:user) + + my_specification = + Repo.insert!(%Specification{ + creator_id: user.id, + concept: "string_collector" + }) + + other_specification = + Repo.insert!(%Specification{ + creator_id: other_user.id, + concept: "string_collector" + }) + + my_answer = + Repo.insert!(%Answer{ + answer: "existing answer", + label: "existing label", + hint: "existing hint", + order: 1, + specification_id: my_specification.id + }) + + other_answer = + Repo.insert!(%Answer{ + answer: "other answer", + order: 1, + specification_id: other_specification.id + }) + + authed_conn = build_conn() + + authed_conn = + post(authed_conn, Routes.api_session_path(authed_conn, :create), %{ + "user" => %{"email" => user.email, "password" => "Xenogenesis"} + }) + + json = json_response(authed_conn, 200) + authorization_token = json["data"]["access_token"] + + {:ok, + conn: + conn + |> put_req_header("accept", "application/vnd.api+json") + |> put_req_header("content-type", "application/vnd.api+json"), + authorization_token: authorization_token, + my_specification: my_specification, + other_specification: other_specification, + my_answer: my_answer, + other_answer: other_answer, + user: user} + end + + describe "create answer" do + test "creates answer for own specification", %{ + conn: conn, + authorization_token: authorization_token, + my_specification: my_specification + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> post(Routes.answer_path(conn, :create), %{ + "data" => %{ + "type" => "answers", + "attributes" => %{ + "answer" => "new answer", + "label" => "new label", + "hint" => "new hint" + }, + "relationships" => %{ + "specification" => %{ + "data" => %{"type" => "specifications", "id" => my_specification.id} + } + } + } + }) + + assert %{"data" => data} = json_response(conn, 201) + assert data["type"] == "answers" + assert data["attributes"]["answer"] == "new answer" + assert data["attributes"]["label"] == "new label" + assert data["attributes"]["hint"] == "new hint" + assert data["attributes"]["order"] == 2 + end + + test "auto-assigns order starting at 1 for empty specification", %{ + conn: conn, + authorization_token: authorization_token + } do + empty_specification = + Repo.insert!(%Specification{ + creator_id: Repo.get_by!(RegistrationsWeb.User, email: "octavia.butler@example.com").id, + concept: "string_collector" + }) + + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> post(Routes.answer_path(conn, :create), %{ + "data" => %{ + "type" => "answers", + "attributes" => %{ + "answer" => "first answer" + }, + "relationships" => %{ + "specification" => %{ + "data" => %{"type" => "specifications", "id" => empty_specification.id} + } + } + } + }) + + assert %{"data" => data} = json_response(conn, 201) + assert data["attributes"]["order"] == 1 + end + + test "returns 401 when creating answer for other user's specification", %{ + conn: conn, + authorization_token: authorization_token, + other_specification: other_specification + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> post(Routes.answer_path(conn, :create), %{ + "data" => %{ + "type" => "answers", + "attributes" => %{ + "answer" => "unauthorized answer" + }, + "relationships" => %{ + "specification" => %{ + "data" => %{"type" => "specifications", "id" => other_specification.id} + } + } + } + }) + + assert json_response(conn, 401)["errors"] == [%{"detail" => "Unauthorized"}] + end + + test "returns 422 when missing required fields", %{ + conn: conn, + authorization_token: authorization_token, + my_specification: my_specification + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> post(Routes.answer_path(conn, :create), %{ + "data" => %{ + "type" => "answers", + "attributes" => %{}, + "relationships" => %{ + "specification" => %{ + "data" => %{"type" => "specifications", "id" => my_specification.id} + } + } + } + }) + + assert json_response(conn, 422) + end + end + + describe "update answer" do + test "updates own answer", %{ + conn: conn, + authorization_token: authorization_token, + my_answer: my_answer + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> patch(Routes.answer_path(conn, :update, my_answer), %{ + "data" => %{ + "type" => "answers", + "id" => my_answer.id, + "attributes" => %{ + "answer" => "updated answer", + "label" => "updated label", + "hint" => "updated hint" + } + } + }) + + assert %{"data" => data} = json_response(conn, 200) + assert data["attributes"]["answer"] == "updated answer" + assert data["attributes"]["label"] == "updated label" + assert data["attributes"]["hint"] == "updated hint" + end + + test "returns 401 when updating other user's answer", %{ + conn: conn, + authorization_token: authorization_token, + other_answer: other_answer + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> patch(Routes.answer_path(conn, :update, other_answer), %{ + "data" => %{ + "type" => "answers", + "id" => other_answer.id, + "attributes" => %{ + "answer" => "unauthorized update" + } + } + }) + + assert json_response(conn, 401)["errors"] == [%{"detail" => "Unauthorized"}] + end + end + + describe "delete answer" do + test "deletes own answer", %{ + conn: conn, + authorization_token: authorization_token, + my_answer: my_answer + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> delete(Routes.answer_path(conn, :delete, my_answer)) + + assert response(conn, 204) + assert_raise Ecto.NoResultsError, fn -> Repo.get!(Answer, my_answer.id) end + end + + test "returns 401 when deleting other user's answer", %{ + conn: conn, + authorization_token: authorization_token, + other_answer: other_answer + } do + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> delete(Routes.answer_path(conn, :delete, other_answer)) + + assert json_response(conn, 401)["errors"] == [%{"detail" => "Unauthorized"}] + assert Repo.get!(Answer, other_answer.id) + end + end +end diff --git a/registrations/test/registrations_web/controllers/specification_controller_test.exs b/registrations/test/registrations_web/controllers/specification_controller_test.exs index 26d7de66..0c0a8f52 100644 --- a/registrations/test/registrations_web/controllers/specification_controller_test.exs +++ b/registrations/test/registrations_web/controllers/specification_controller_test.exs @@ -393,4 +393,94 @@ defmodule RegistrationsWeb.SpecificationControllerTest do ]) end end + + describe "create specification" do + setup do + user = insert(:octavia, admin: true) + + region = + Repo.insert!(%Region{ + name: "Test Region", + geom: %Geo.Point{coordinates: {-97.0, 40.1}, srid: 4326} + }) + + authed_conn = build_conn() + + authed_conn = + post(authed_conn, Routes.api_session_path(authed_conn, :create), %{ + "user" => %{"email" => user.email, "password" => "Xenogenesis"} + }) + + json = json_response(authed_conn, 200) + authorization_token = json["data"]["access_token"] + + %{ + authorization_token: authorization_token, + region: region, + user: user + } + end + + test "creates a new specification", %{ + conn: conn, + authorization_token: authorization_token, + region: region, + user: user + } do + create_params = %{ + "data" => %{ + "type" => "specifications", + "attributes" => %{ + "concept" => "bluetooth_collector", + "task_description" => "Find all the devices", + "start_description" => "Start at the entrance", + "duration" => 120, + "region_id" => region.id, + "notes" => "Some notes" + } + } + } + + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> post(Routes.specification_path(conn, :create), create_params) + + response = json_response(conn, 201) + assert response["data"]["id"] + assert response["data"]["type"] == "specifications" + + created_specification = Repo.get!(Specification, response["data"]["id"]) + assert created_specification.concept == "bluetooth_collector" + assert created_specification.task_description == "Find all the devices" + assert created_specification.start_description == "Start at the entrance" + assert created_specification.duration == 120 + assert created_specification.region_id == region.id + assert created_specification.notes == "Some notes" + assert created_specification.creator_id == user.id + end + + test "returns error with invalid attributes", %{ + conn: conn, + authorization_token: authorization_token + } do + create_params = %{ + "data" => %{ + "type" => "specifications", + "attributes" => %{ + "concept" => "invalid_concept", + "task_description" => "" + } + } + } + + conn = + conn + |> put_req_header("authorization", "#{authorization_token}") + |> post(Routes.specification_path(conn, :create), create_params) + + assert %{"errors" => errors} = json_response(conn, 422) + assert length(errors) > 0 + end + end end diff --git a/waydowntown_app/integration_test/edit_specification_test.dart b/waydowntown_app/integration_test/edit_specification_test.dart new file mode 100644 index 00000000..26c796d5 --- /dev/null +++ b/waydowntown_app/integration_test/edit_specification_test.dart @@ -0,0 +1,140 @@ +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/app.dart'; +import 'package:waydowntown/services/user_service.dart'; + +import 'helpers.dart'; +import 'test_backend_client.dart'; +import 'test_config.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late TestBackendClient testClient; + + setUp(() async { + FlutterSecureStorage.setMockInitialValues({}); + dotenv.testLoad(fileInput: 'API_ROOT=${TestConfig.apiBaseUrl}'); + testClient = TestBackendClient(); + }); + + testWidgets('edit specification: add and modify string_collector answers', + (tester) async { + // 1. Reset DB with string_collector game (creator_id set to test user) + final resetData = + await testClient.resetDatabase(game: 'string_collector'); + final email = resetData['email'] as String; + + // 2. Login and set tokens + final tokens = + await testClient.login(email, resetData['password'] as String); + await UserService.setTokens(tokens.accessToken, tokens.renewalToken); + + // 3. Launch app + await tester.pumpWidget(const Waydowntown()); + await waitFor(tester, find.text(email)); + + // 4. Navigate to My specifications + final mySpecsButton = find.text('My specifications'); + await tester.ensureVisible(mySpecsButton); + await tester.tap(mySpecsButton); + + // 5. Wait for specifications table to load + await waitFor(tester, find.text('My Specifications'), + timeout: const Duration(seconds: 15)); + await waitFor(tester, find.text('3'), + timeout: const Duration(seconds: 15)); + + // 6. Tap edit button on the specification + final editButton = find.byIcon(Icons.edit); + await waitFor(tester, editButton); + await tester.scrollUntilVisible( + editButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(editButton); + + // 7. Wait for edit screen to load + await waitFor(tester, find.text('Edit Specification'), + timeout: const Duration(seconds: 15)); + + // 8. Verify existing answers are loaded + await waitFor(tester, find.byKey(const Key('answer-card-0'))); + expect(find.byKey(const Key('answer-card-1')), findsOneWidget); + expect(find.byKey(const Key('answer-card-2')), findsOneWidget); + + // 9. Scroll down and add a new answer + final addButton = find.byKey(const Key('add-answer')); + await tester.scrollUntilVisible( + addButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(addButton); + await tester.pumpAndSettle(); + + // 10. Fill in the new answer + final newAnswerField = find.byKey(const Key('answer-text-3')); + await tester.scrollUntilVisible( + newAnswerField, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.enterText(newAnswerField, 'dragonfruit'); + + // 11. Add a hint for the new answer + final newHintField = find.byKey(const Key('answer-hint-3')); + await tester.scrollUntilVisible( + newHintField, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.enterText(newHintField, 'near the exit'); + + // 12. Save + final saveButton = find.text('Save'); + await tester.scrollUntilVisible( + saveButton, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(saveButton); + + // 13. Should return to specifications table with updated answer count + await waitFor(tester, find.text('My Specifications'), + timeout: const Duration(seconds: 15)); + await waitFor(tester, find.text('4'), + timeout: const Duration(seconds: 15)); + + // 14. Re-open edit to verify the new answer persisted + final editButton2 = find.byIcon(Icons.edit); + await waitFor(tester, editButton2); + await tester.scrollUntilVisible( + editButton2, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(editButton2); + await waitFor(tester, find.text('Edit Specification'), + timeout: const Duration(seconds: 15)); + + // 15. Verify all 4 answers are present + await waitFor(tester, find.byKey(const Key('answer-card-3'))); + + // 16. Scroll down to verify the new answer's text + final answerField3 = find.byKey(const Key('answer-text-3')); + await tester.scrollUntilVisible( + answerField3, + 200.0, + scrollable: find.byType(Scrollable).first, + ); + + final answerTextField = + tester.widget(answerField3); + expect(answerTextField.controller!.text, equals('dragonfruit')); + }); +} diff --git a/waydowntown_app/integration_test/helpers.dart b/waydowntown_app/integration_test/helpers.dart index a26889dc..d89e1cf4 100644 --- a/waydowntown_app/integration_test/helpers.dart +++ b/waydowntown_app/integration_test/helpers.dart @@ -3,17 +3,18 @@ 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. +/// Pumps frames until [finder] matches at least [count] widgets, or throws on timeout. Future waitFor( WidgetTester tester, Finder finder, { Duration timeout = const Duration(seconds: 15), Finder? failOn, + int count = 1, }) 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 (finder.evaluate().length >= count) return; if (failOn != null && failOn.evaluate().isNotEmpty) { throw TestFailure( 'Found fail-on widget while waiting for $finder: $failOn'); diff --git a/waydowntown_app/lib/games/bluetooth_collector.dart b/waydowntown_app/lib/games/bluetooth_collector.dart index 85498b89..49db8d69 100644 --- a/waydowntown_app/lib/games/bluetooth_collector.dart +++ b/waydowntown_app/lib/games/bluetooth_collector.dart @@ -41,6 +41,7 @@ class BluetoothCollectorGame extends StatelessWidget { class BluetoothDetector implements StringDetector { final FlutterBluePlusMockable flutterBluePlus; final _detectedDevicesController = StreamController.broadcast(); + StreamSubscription? _scanSubscription; BluetoothDetector(this.flutterBluePlus); @@ -49,7 +50,7 @@ class BluetoothDetector implements StringDetector { @override void startDetecting() { - flutterBluePlus.onScanResults.listen((results) { + _scanSubscription = flutterBluePlus.onScanResults.listen((results) { for (var result in results) { if (result.device.platformName.isNotEmpty) { _detectedDevicesController.add(result.device.platformName); @@ -71,6 +72,7 @@ class BluetoothDetector implements StringDetector { @override void dispose() { FlutterBluePlus.stopScan(); + _scanSubscription?.cancel(); _detectedDevicesController.close(); } } diff --git a/waydowntown_app/lib/models/answer.dart b/waydowntown_app/lib/models/answer.dart index 1e982195..45e21405 100644 --- a/waydowntown_app/lib/models/answer.dart +++ b/waydowntown_app/lib/models/answer.dart @@ -5,6 +5,7 @@ class Answer { final String? label; final int? order; final Region? region; + final String? answer; final String? hint; final bool hasHint; @@ -14,6 +15,7 @@ class Answer { required this.label, this.order, this.region, + this.answer, this.hint, this.hasHint = false, }); @@ -43,6 +45,7 @@ class Answer { label: attributes?['label'], order: attributes?['order'], region: region, + answer: attributes?['answer'], hint: attributes?['hint'], hasHint: attributes?['has_hint'] == true, ); diff --git a/waydowntown_app/lib/tools/my_specifications_table.dart b/waydowntown_app/lib/tools/my_specifications_table.dart index 2ebedde1..0b99da0e 100644 --- a/waydowntown_app/lib/tools/my_specifications_table.dart +++ b/waydowntown_app/lib/tools/my_specifications_table.dart @@ -14,10 +14,13 @@ class MySpecificationsTable extends StatefulWidget { _MySpecificationsTableState createState() => _MySpecificationsTableState(); } +enum GroupBy { region, concept } + class _MySpecificationsTableState extends State { List specifications = []; bool isLoading = true; bool isRequestError = false; + GroupBy _groupBy = GroupBy.region; @override void initState() { @@ -70,6 +73,52 @@ class _MySpecificationsTableState extends State { return Scaffold( appBar: AppBar( title: const Text('My Specifications'), + actions: [ + ToggleButtons( + key: const Key('group-by-toggle'), + isSelected: [ + _groupBy == GroupBy.region, + _groupBy == GroupBy.concept, + ], + onPressed: (index) { + setState(() { + _groupBy = index == 0 ? GroupBy.region : GroupBy.concept; + }); + }, + children: const [ + Padding( + key: Key('group-by-region'), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text('Region'), + ), + Padding( + key: Key('group-by-concept'), + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text('Concept'), + ), + ], + ), + const SizedBox(width: 8), + IconButton( + key: const Key('new-specification'), + icon: const Icon(Icons.add), + onPressed: () async { + final didCreate = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => EditSpecificationWidget( + dio: widget.dio, + ), + ), + ); + + if (!mounted) return; + + if (didCreate == true) { + await fetchMySpecifications(); + } + }, + ), + ], ), body: Builder( builder: (BuildContext context) { @@ -84,12 +133,15 @@ class _MySpecificationsTableState extends State { child: Theme( data: Theme.of(context).copyWith(), child: DataTable( - columns: const [ - DataColumn(label: Text('Concept')), - DataColumn(label: Text('Answers')), - DataColumn(label: Text('Start')), - DataColumn(label: Text('Task')), - DataColumn(label: Text('')), + columns: [ + DataColumn( + label: Text(_groupBy == GroupBy.region + ? 'Concept' + : 'Region')), + const DataColumn(label: Text('Answers')), + const DataColumn(label: Text('Start')), + const DataColumn(label: Text('Task')), + const DataColumn(label: Text('')), ], rows: _buildTableRows(), ), @@ -105,22 +157,35 @@ class _MySpecificationsTableState extends State { List _buildTableRows() { Map> groupedSpecs = {}; for (var spec in specifications) { - String regionName = spec.region?.name ?? 'Unknown'; - if (!groupedSpecs.containsKey(regionName)) { - groupedSpecs[regionName] = []; + String groupKey = _groupBy == GroupBy.region + ? (spec.region?.name ?? 'Unknown') + : spec.concept; + if (!groupedSpecs.containsKey(groupKey)) { + groupedSpecs[groupKey] = []; } - groupedSpecs[regionName]!.add(spec); + groupedSpecs[groupKey]!.add(spec); } List rows = []; - groupedSpecs.forEach((regionName, specs) { + final sortedKeys = groupedSpecs.keys.toList()..sort(); + for (final groupKey in sortedKeys) { + final specs = groupedSpecs[groupKey]!; + if (_groupBy == GroupBy.concept) { + specs.sort((a, b) => (a.region?.name ?? 'Unknown') + .compareTo(b.region?.name ?? 'Unknown')); + } else { + specs.sort((a, b) => a.concept.compareTo(b.concept)); + } + } + for (final groupKey in sortedKeys) { + final specs = groupedSpecs[groupKey]!; rows.add(DataRow( cells: [ DataCell( Container( color: Theme.of(context).primaryColor.withOpacity(0.1), child: Text( - regionName, + groupKey, style: const TextStyle(fontWeight: FontWeight.bold), ), ), @@ -133,7 +198,9 @@ class _MySpecificationsTableState extends State { )); rows.addAll(specs.map((spec) => DataRow( cells: [ - DataCell(Text(spec.concept)), + DataCell(Text(_groupBy == GroupBy.region + ? spec.concept + : (spec.region?.name ?? 'Unknown'))), DataCell(Text(spec.answers?.length.toString() ?? '0')), DataCell(_truncatedText(spec.startDescription)), DataCell(_truncatedText(spec.taskDescription)), @@ -177,7 +244,7 @@ class _MySpecificationsTableState extends State { )), ], ))); - }); + } return rows; } diff --git a/waydowntown_app/lib/widgets/edit_specification_widget.dart b/waydowntown_app/lib/widgets/edit_specification_widget.dart index 871c0852..9b6705f2 100644 --- a/waydowntown_app/lib/widgets/edit_specification_widget.dart +++ b/waydowntown_app/lib/widgets/edit_specification_widget.dart @@ -2,19 +2,84 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:waydowntown/app.dart'; +import 'package:waydowntown/models/answer.dart'; import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/specification.dart'; import 'package:waydowntown/widgets/edit_region_form.dart'; +import 'package:waydowntown/widgets/sensor_answer_registry.dart'; +import 'package:waydowntown/widgets/sensor_answer_scanner.dart'; import 'package:yaml/yaml.dart'; +class _AnswerEditState { + final String? id; + final bool isNew; + final TextEditingController answerController; + final TextEditingController labelController; + final TextEditingController hintController; + final String _originalAnswer; + final String _originalLabel; + final String _originalHint; + bool isEditUnlocked; + + _AnswerEditState({ + this.id, + this.isNew = true, + this.isEditUnlocked = true, + required this.answerController, + required this.labelController, + required this.hintController, + String originalAnswer = '', + String originalLabel = '', + String originalHint = '', + }) : _originalAnswer = originalAnswer, + _originalLabel = originalLabel, + _originalHint = originalHint; + + factory _AnswerEditState.fromAnswer(Answer answer) { + final answerText = answer.answer ?? ''; + final label = answer.label ?? ''; + final hint = answer.hint ?? ''; + return _AnswerEditState( + id: answer.id, + isNew: false, + isEditUnlocked: false, + answerController: TextEditingController(text: answerText), + labelController: TextEditingController(text: label), + hintController: TextEditingController(text: hint), + originalAnswer: answerText, + originalLabel: label, + originalHint: hint, + ); + } + + factory _AnswerEditState.empty() { + return _AnswerEditState( + answerController: TextEditingController(), + labelController: TextEditingController(), + hintController: TextEditingController(), + ); + } + + bool get isDirty => + answerController.text != _originalAnswer || + labelController.text != _originalLabel || + hintController.text != _originalHint; + + void dispose() { + answerController.dispose(); + labelController.dispose(); + hintController.dispose(); + } +} + class EditSpecificationWidget extends StatefulWidget { final Dio dio; - final Specification specification; + final Specification? specification; const EditSpecificationWidget({ super.key, required this.dio, - required this.specification, + this.specification, }); @override @@ -33,19 +98,28 @@ class EditSpecificationWidgetState extends State { bool _sortByDistance = false; dynamic _concepts; bool _isLoading = true; + List<_AnswerEditState> _answers = []; + final List _deletedAnswerIds = []; + String? _createdSpecificationId; + + bool get _isCreateMode => widget.specification == null; @override void initState() { super.initState(); + final spec = widget.specification; _startDescriptionController = - TextEditingController(text: widget.specification.startDescription); + TextEditingController(text: spec?.startDescription); _taskDescriptionController = - TextEditingController(text: widget.specification.taskDescription); + TextEditingController(text: spec?.taskDescription); _durationController = TextEditingController( - text: widget.specification.duration?.toString() ?? ''); - _notesController = TextEditingController(text: widget.specification.notes); - _selectedConcept = widget.specification.concept; - _selectedRegionId = widget.specification.region?.id; + text: spec?.duration?.toString() ?? ''); + _notesController = TextEditingController(text: spec?.notes); + _selectedConcept = spec?.concept; + _selectedRegionId = spec?.region?.id; + _answers = (spec?.answers ?? []) + .map((a) => _AnswerEditState.fromAnswer(a)) + .toList(); _loadRegions(); } @@ -119,7 +193,7 @@ class EditSpecificationWidgetState extends State { return Scaffold( appBar: AppBar( - title: const Text('Edit Specification'), + title: Text(_isCreateMode ? 'New Specification' : 'Edit Specification'), ), body: SingleChildScrollView( child: Padding( @@ -136,6 +210,8 @@ class EditSpecificationWidgetState extends State { _buildTextField('Notes', _notesController, 'notes'), _buildDurationField(), const SizedBox(height: 16), + _buildAnswersSection(), + const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -332,31 +408,314 @@ class EditSpecificationWidgetState extends State { }); } + Widget _buildAnswersSection() { + final sensorConfig = + SensorAnswerRegistry.configForConcept(_selectedConcept); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Answers', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Row( + children: [ + if (sensorConfig != null) + IconButton( + key: const Key('scan-answers'), + onPressed: () => _openScanner(sensorConfig), + icon: Icon(sensorConfig.icon), + ), + IconButton( + key: const Key('add-answer'), + onPressed: _addAnswer, + icon: const Icon(Icons.add), + ), + ], + ), + ], + ), + ..._answers.asMap().entries.map((entry) { + final index = entry.key; + final answer = entry.value; + return _buildAnswerCard(answer, index); + }), + ], + ); + } + + Widget _buildAnswerCard(_AnswerEditState answer, int index) { + final isSensorConcept = + SensorAnswerRegistry.configForConcept(_selectedConcept) != null; + final isLocked = isSensorConcept && !answer.isNew && !answer.isEditUnlocked; + + return Card( + key: Key('answer-card-$index'), + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: GestureDetector( + behavior: isLocked ? HitTestBehavior.opaque : HitTestBehavior.deferToChild, + onTap: isLocked ? () => _unlockAnswer(index) : null, + child: AbsorbPointer( + absorbing: isLocked, + child: TextField( + key: Key('answer-text-$index'), + controller: answer.answerController, + decoration: InputDecoration( + labelText: 'Answer', + isDense: true, + suffixIcon: isLocked + ? const Icon(Icons.lock, size: 16) + : null, + ), + ), + ), + ), + ), + IconButton( + key: Key('delete-answer-$index'), + onPressed: () => _deleteAnswer(index), + icon: const Icon(Icons.delete), + ), + ], + ), + TextField( + key: Key('answer-label-$index'), + controller: answer.labelController, + decoration: const InputDecoration( + labelText: 'Label', + isDense: true, + ), + ), + TextField( + key: Key('answer-hint-$index'), + controller: answer.hintController, + decoration: const InputDecoration( + labelText: 'Hint', + isDense: true, + ), + ), + ], + ), + ), + ); + } + + void _unlockAnswer(int index) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Edit sensor answer'), + content: const Text( + 'This answer was produced by sensor input. Editing it may cause it to no longer correspond to real-world data.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('confirm-edit-answer'), + onPressed: () { + Navigator.of(context).pop(); + setState(() { + _answers[index].isEditUnlocked = true; + }); + }, + child: const Text('Edit anyway'), + ), + ], + ); + }, + ); + } + + void _addAnswer() { + final sensorConfig = + SensorAnswerRegistry.configForConcept(_selectedConcept); + if (sensorConfig != null) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Manual answer'), + content: const Text( + 'This concept uses sensor input. Manually added answers may not correspond to real-world data.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('confirm-manual-answer'), + onPressed: () { + Navigator.of(context).pop(); + setState(() { + _answers.add(_AnswerEditState.empty()); + }); + }, + child: const Text('Add anyway'), + ), + ], + ); + }, + ); + } else { + setState(() { + _answers.add(_AnswerEditState.empty()); + }); + } + } + + Future _openScanner(SensorConfig config) async { + final results = await Navigator.of(context).push>( + MaterialPageRoute( + builder: (context) => SensorAnswerScanner( + detector: config.detectorFactory(), + inputBuilder: config.inputBuilder, + title: config.title, + ), + ), + ); + + if (results != null && results.isNotEmpty) { + setState(() { + for (final scanned in results) { + final answer = _AnswerEditState.empty(); + answer.answerController.text = scanned.answer; + if (scanned.hint != null) { + answer.hintController.text = scanned.hint!; + } + _answers.add(answer); + } + }); + } + } + + void _deleteAnswer(int index) { + final answer = _answers[index]; + if (!answer.isNew && answer.id != null) { + _deletedAnswerIds.add(answer.id!); + } + answer.dispose(); + setState(() { + _answers.removeAt(index); + }); + } + + Future _saveAnswers(String specificationId) async { + for (final id in _deletedAnswerIds) { + await widget.dio.delete('/waydowntown/answers/$id'); + } + + for (var i = 0; i < _answers.length; i++) { + final answer = _answers[i]; + final answerText = answer.answerController.text; + final label = answer.labelController.text; + final hint = answer.hintController.text; + + if (answer.isNew) { + await widget.dio.post( + '/waydowntown/answers', + data: { + 'data': { + 'type': 'answers', + 'attributes': { + 'answer': answerText, + 'label': label.isEmpty ? null : label, + 'hint': hint.isEmpty ? null : hint, + }, + 'relationships': { + 'specification': { + 'data': { + 'type': 'specifications', + 'id': specificationId, + }, + }, + }, + }, + }, + ); + } else if (answer.id != null && answer.isDirty) { + await widget.dio.patch( + '/waydowntown/answers/${answer.id}', + data: { + 'data': { + 'type': 'answers', + 'id': answer.id, + 'attributes': { + 'answer': answerText, + 'label': label.isEmpty ? null : label, + 'hint': hint.isEmpty ? null : hint, + }, + }, + }, + ); + } + } + } + Future _saveSpecification() async { try { - final response = await widget.dio.patch( - '/waydowntown/specifications/${widget.specification.id}', - data: { - 'data': { - 'type': 'specifications', - 'id': widget.specification.id, - 'attributes': { - 'concept': _selectedConcept, - 'start_description': _startDescriptionController.text, - 'task_description': _taskDescriptionController.text, - 'duration': int.tryParse(_durationController.text), - 'region_id': _selectedRegionId, - 'notes': _notesController.text, + final Response response; + final String specificationId; + + if (_isCreateMode) { + response = await widget.dio.post( + '/waydowntown/specifications', + data: { + 'data': { + 'type': 'specifications', + 'attributes': { + 'concept': _selectedConcept, + 'start_description': _startDescriptionController.text, + 'task_description': _taskDescriptionController.text, + 'duration': int.tryParse(_durationController.text), + 'region_id': _selectedRegionId, + 'notes': _notesController.text, + }, }, }, - }, - ); + ); + specificationId = response.data['data']['id']; + _createdSpecificationId = specificationId; + } else { + response = await widget.dio.patch( + '/waydowntown/specifications/${widget.specification!.id}', + data: { + 'data': { + 'type': 'specifications', + 'id': widget.specification!.id, + 'attributes': { + 'concept': _selectedConcept, + 'start_description': _startDescriptionController.text, + 'task_description': _taskDescriptionController.text, + 'duration': int.tryParse(_durationController.text), + 'region_id': _selectedRegionId, + 'notes': _notesController.text, + }, + }, + }, + ); + specificationId = widget.specification!.id; + } - if (response.statusCode == 200 && mounted) { - Navigator.of(context).pop(true); + if (response.statusCode == 200 || response.statusCode == 201) { + await _saveAnswers(specificationId); + if (mounted) { + Navigator.of(context).pop(true); + } } } on DioException catch (e) { - talker.error('Error updating specification: $e'); + talker.error('Error saving specification: $e'); if (e.response?.statusCode == 422) { final errors = e.response?.data['errors'] as List; setState(() { @@ -369,7 +728,7 @@ class EditSpecificationWidgetState extends State { }); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to update specification')), + SnackBar(content: Text('Failed to ${_isCreateMode ? 'create' : 'update'} specification')), ); } } @@ -404,6 +763,9 @@ class EditSpecificationWidgetState extends State { _taskDescriptionController.dispose(); _durationController.dispose(); _notesController.dispose(); + for (final answer in _answers) { + answer.dispose(); + } super.dispose(); } } diff --git a/waydowntown_app/lib/widgets/sensor_answer_registry.dart b/waydowntown_app/lib/widgets/sensor_answer_registry.dart new file mode 100644 index 00000000..f248e451 --- /dev/null +++ b/waydowntown_app/lib/widgets/sensor_answer_registry.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:waydowntown/flutter_blue_plus_mockable.dart'; +import 'package:waydowntown/games/bluetooth_collector.dart'; +import 'package:waydowntown/games/code_collector.dart'; +import 'package:waydowntown/games/collector_game.dart'; + +class SensorConfig { + final String title; + final StringDetector Function() detectorFactory; + final Widget Function(BuildContext, StringDetector) inputBuilder; + final IconData icon; + + const SensorConfig({ + required this.title, + required this.detectorFactory, + required this.inputBuilder, + required this.icon, + }); +} + +class SensorAnswerRegistry { + static SensorConfig? configForConcept(String? concept) { + switch (concept) { + case 'bluetooth_collector': + return SensorConfig( + title: 'Scan Bluetooth Devices', + detectorFactory: () => BluetoothDetector(FlutterBluePlusMockable()), + inputBuilder: (_, __) => const SizedBox(), + icon: Icons.bluetooth_searching, + ); + case 'code_collector': + return SensorConfig( + title: 'Scan Barcodes', + detectorFactory: () => CodeDetector(null), + inputBuilder: (context, detector) => SizedBox( + height: 300, + child: MobileScanner( + controller: (detector as CodeDetector).controller, + ), + ), + icon: Icons.qr_code_scanner, + ); + default: + return null; + } + } +} diff --git a/waydowntown_app/lib/widgets/sensor_answer_scanner.dart b/waydowntown_app/lib/widgets/sensor_answer_scanner.dart new file mode 100644 index 00000000..b7a3eeca --- /dev/null +++ b/waydowntown_app/lib/widgets/sensor_answer_scanner.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:waydowntown/games/collector_game.dart'; + +class ScannedAnswer { + final String answer; + final String? hint; + + const ScannedAnswer({required this.answer, this.hint}); +} + +class _DetectedEntry { + final String value; + bool included; + final TextEditingController hintController; + + _DetectedEntry(this.value) + : included = false, + hintController = TextEditingController(); + + void dispose() { + hintController.dispose(); + } +} + +class SensorAnswerScanner extends StatefulWidget { + final StringDetector detector; + final Widget Function(BuildContext, StringDetector) inputBuilder; + final String title; + + const SensorAnswerScanner({ + super.key, + required this.detector, + required this.inputBuilder, + required this.title, + }); + + @override + State createState() => _SensorAnswerScannerState(); +} + +class _SensorAnswerScannerState extends State { + final List<_DetectedEntry> _entries = []; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _subscription = widget.detector.detectedStrings.listen(_onDetected); + widget.detector.startDetecting(); + } + + void _onDetected(String value) { + if (!_entries.any((e) => e.value == value)) { + setState(() { + _entries.insert(0, _DetectedEntry(value)); + }); + } + } + + List _buildResult() { + return _entries + .where((e) => e.included) + .map((e) => ScannedAnswer( + answer: e.value, + hint: e.hintController.text.isEmpty + ? null + : e.hintController.text, + )) + .toList(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + Navigator.pop(context, _buildResult()); + } + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context, _buildResult()), + ), + title: Text(widget.title), + actions: [ + TextButton( + key: const Key('scanner-done'), + onPressed: () => Navigator.pop(context, _buildResult()), + child: const Text('Done'), + ), + ], + ), + body: Column( + children: [ + widget.inputBuilder(context, widget.detector), + Expanded( + child: _entries.isEmpty + ? const Center(child: Text('Scanning...')) + : ListView.builder( + itemCount: _entries.length, + itemBuilder: (context, index) { + final entry = _entries[index]; + return Card( + key: Key('scanned-item-$index'), + margin: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + setState(() { + entry.included = !entry.included; + }); + }, + child: Row( + children: [ + Checkbox( + key: Key('include-$index'), + value: entry.included, + onChanged: (value) { + setState(() { + entry.included = value ?? false; + }); + }, + ), + Expanded( + child: Text( + entry.value, + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + if (entry.included) + Padding( + padding: const EdgeInsets.only( + left: 48, right: 8, bottom: 8), + child: TextField( + key: Key('hint-$index'), + controller: entry.hintController, + decoration: const InputDecoration( + labelText: 'Hint', + isDense: true, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _subscription?.cancel(); + widget.detector.dispose(); + for (final entry in _entries) { + entry.dispose(); + } + super.dispose(); + } +} diff --git a/waydowntown_app/test/tools/my_specifications_table_test.dart b/waydowntown_app/test/tools/my_specifications_table_test.dart new file mode 100644 index 00000000..a18921cd --- /dev/null +++ b/waydowntown_app/test/tools/my_specifications_table_test.dart @@ -0,0 +1,338 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:waydowntown/tools/my_specifications_table.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + + setUp(() { + dio = Dio(BaseOptions(baseUrl: 'http://example.com')); + dioAdapter = DioAdapter(dio: dio); + dio.httpClientAdapter = dioAdapter; + }); + + Map buildSpecificationsResponse() { + return { + 'data': [ + { + 'id': 'spec1', + 'type': 'specifications', + 'attributes': { + 'concept': 'string_collector', + 'placed': true, + 'start_description': 'Start 1', + 'task_description': 'Task 1', + }, + 'relationships': { + 'region': { + 'data': {'type': 'regions', 'id': 'region1'} + }, + 'answers': { + 'data': [ + {'type': 'answers', 'id': 'a1'} + ] + }, + }, + }, + { + 'id': 'spec2', + 'type': 'specifications', + 'attributes': { + 'concept': 'string_collector', + 'placed': true, + 'start_description': 'Start 2', + 'task_description': 'Task 2', + }, + 'relationships': { + 'region': { + 'data': {'type': 'regions', 'id': 'region2'} + }, + 'answers': { + 'data': [ + {'type': 'answers', 'id': 'a2'} + ] + }, + }, + }, + { + 'id': 'spec3', + 'type': 'specifications', + 'attributes': { + 'concept': 'fill_in_the_blank', + 'placed': true, + 'start_description': 'Start 3', + 'task_description': 'Task 3', + }, + 'relationships': { + 'region': { + 'data': {'type': 'regions', 'id': 'region1'} + }, + 'answers': { + 'data': [ + {'type': 'answers', 'id': 'a3'} + ] + }, + }, + }, + ], + 'included': [ + { + 'id': 'region1', + 'type': 'regions', + 'attributes': {'name': 'Downtown Mall'}, + 'relationships': { + 'parent': {'data': null} + }, + }, + { + 'id': 'region2', + 'type': 'regions', + 'attributes': {'name': 'City Park'}, + 'relationships': { + 'parent': {'data': null} + }, + }, + { + 'id': 'a1', + 'type': 'answers', + 'attributes': {'label': 'A1'}, + 'relationships': { + 'specification': { + 'data': {'type': 'specifications', 'id': 'spec1'} + } + }, + }, + { + 'id': 'a2', + 'type': 'answers', + 'attributes': {'label': 'A2'}, + 'relationships': { + 'specification': { + 'data': {'type': 'specifications', 'id': 'spec2'} + } + }, + }, + { + 'id': 'a3', + 'type': 'answers', + 'attributes': {'label': 'A3'}, + 'relationships': { + 'specification': { + 'data': {'type': 'specifications', 'id': 'spec3'} + } + }, + }, + ], + }; + } + + testWidgets('defaults to grouping by region', (WidgetTester tester) async { + dioAdapter.onGet( + '/waydowntown/specifications/mine', + (server) => server.reply(200, buildSpecificationsResponse()), + ); + + await tester.pumpWidget(MaterialApp( + home: MySpecificationsTable(dio: dio), + )); + + await tester.pumpAndSettle(); + + // Region headers should appear as group labels + // Downtown Mall has spec1 (string_collector) and spec3 (fill_in_the_blank) + // City Park has spec2 (string_collector) + final allText = find.byType(Text); + final textWidgets = tester.widgetList(allText).toList(); + final textValues = textWidgets.map((t) => t.data).whereType().toList(); + + expect(textValues, contains('Downtown Mall')); + expect(textValues, contains('City Park')); + + // Region toggle should be selected + final toggleButtons = + tester.widget(find.byKey(const Key('group-by-toggle'))); + expect(toggleButtons.isSelected, equals([true, false])); + }); + + testWidgets('can switch to grouping by concept', + (WidgetTester tester) async { + dioAdapter.onGet( + '/waydowntown/specifications/mine', + (server) => server.reply(200, buildSpecificationsResponse()), + ); + + await tester.pumpWidget(MaterialApp( + home: MySpecificationsTable(dio: dio), + )); + + await tester.pumpAndSettle(); + + // Verify initial region grouping + var textValues = _getAllTextValues(tester); + expect(textValues, contains('Downtown Mall')); + expect(textValues, contains('City Park')); + + // Tap the Concept toggle + await tester.tap(find.byKey(const Key('group-by-concept'))); + await tester.pumpAndSettle(); + + // Now should be grouped by concept + textValues = _getAllTextValues(tester); + expect(textValues, contains('string_collector')); + expect(textValues, contains('fill_in_the_blank')); + + // Concept toggle should now be selected + final toggleButtons = + tester.widget(find.byKey(const Key('group-by-toggle'))); + expect(toggleButtons.isSelected, equals([false, true])); + }); + + testWidgets('can switch back to grouping by region', + (WidgetTester tester) async { + dioAdapter.onGet( + '/waydowntown/specifications/mine', + (server) => server.reply(200, buildSpecificationsResponse()), + ); + + await tester.pumpWidget(MaterialApp( + home: MySpecificationsTable(dio: dio), + )); + + await tester.pumpAndSettle(); + + // Switch to concept grouping + await tester.tap(find.byKey(const Key('group-by-concept'))); + await tester.pumpAndSettle(); + + var textValues = _getAllTextValues(tester); + expect(textValues, contains('string_collector')); + + // Switch back to region grouping + await tester.tap(find.byKey(const Key('group-by-region'))); + await tester.pumpAndSettle(); + + textValues = _getAllTextValues(tester); + expect(textValues, contains('Downtown Mall')); + expect(textValues, contains('City Park')); + + final toggleButtons = + tester.widget(find.byKey(const Key('group-by-toggle'))); + expect(toggleButtons.isSelected, equals([true, false])); + }); + + testWidgets( + 'concept grouping shows region in first column and swaps column header', + (WidgetTester tester) async { + dioAdapter.onGet( + '/waydowntown/specifications/mine', + (server) => server.reply(200, buildSpecificationsResponse()), + ); + + await tester.pumpWidget(MaterialApp( + home: MySpecificationsTable(dio: dio), + )); + + await tester.pumpAndSettle(); + + // Default: first column header is "Concept" + expect(find.text('Concept'), findsWidgets); + + // Switch to concept grouping + await tester.tap(find.byKey(const Key('group-by-concept'))); + await tester.pumpAndSettle(); + + // First column header should now be "Region" + final textValues = _getAllTextValues(tester); + expect(textValues.where((t) => t == 'Region').length, greaterThanOrEqualTo(2)); + + // Group headers should be concept names + // string_collector: 1 header only (data rows show region, not concept) + expect(find.text('string_collector'), findsOneWidget); + expect(find.text('fill_in_the_blank'), findsOneWidget); + + // Data rows under string_collector should show region names + // spec1 is in Downtown Mall, spec2 is in City Park + expect(find.text('Downtown Mall'), findsNWidgets(2)); + expect(find.text('City Park'), findsOneWidget); + }); + + testWidgets('concept grouping sorts rows by region within each group', + (WidgetTester tester) async { + dioAdapter.onGet( + '/waydowntown/specifications/mine', + (server) => server.reply(200, buildSpecificationsResponse()), + ); + + await tester.pumpWidget(MaterialApp( + home: MySpecificationsTable(dio: dio), + )); + + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('group-by-concept'))); + await tester.pumpAndSettle(); + + // Groups sorted alphabetically: fill_in_the_blank, then string_collector + // Within string_collector: City Park (spec2) before Downtown Mall (spec1) + final firstColumnTexts = _getFirstColumnDataTexts(tester); + + // fill_in_the_blank group header, then Downtown Mall row, + // string_collector group header, then City Park row, then Downtown Mall row + expect(firstColumnTexts, [ + 'fill_in_the_blank', + 'Downtown Mall', + 'string_collector', + 'City Park', + 'Downtown Mall', + ]); + }); + + testWidgets('region grouping sorts rows by concept within each group', + (WidgetTester tester) async { + dioAdapter.onGet( + '/waydowntown/specifications/mine', + (server) => server.reply(200, buildSpecificationsResponse()), + ); + + await tester.pumpWidget(MaterialApp( + home: MySpecificationsTable(dio: dio), + )); + + await tester.pumpAndSettle(); + + // Default is region grouping + // Groups sorted alphabetically: City Park, then Downtown Mall + // Within Downtown Mall: fill_in_the_blank (spec3) before string_collector (spec1) + final firstColumnTexts = _getFirstColumnDataTexts(tester); + + expect(firstColumnTexts, [ + 'City Park', + 'string_collector', + 'Downtown Mall', + 'fill_in_the_blank', + 'string_collector', + ]); + }); +} + +List _getAllTextValues(WidgetTester tester) { + return tester + .widgetList(find.byType(Text)) + .map((t) => t.data) + .whereType() + .toList(); +} + +List _getFirstColumnDataTexts(WidgetTester tester) { + final dataTable = tester.widget(find.byType(DataTable)); + return dataTable.rows.map((row) { + final cell = row.cells.first; + final widget = (cell.child is Container) + ? ((cell.child as Container).child as Text) + : cell.child as Text; + return widget.data!; + }).toList(); +} diff --git a/waydowntown_app/test/widgets/edit_specification_widget_test.dart b/waydowntown_app/test/widgets/edit_specification_widget_test.dart index 01db0c87..ad4f835e 100644 --- a/waydowntown_app/test/widgets/edit_specification_widget_test.dart +++ b/waydowntown_app/test/widgets/edit_specification_widget_test.dart @@ -8,6 +8,7 @@ import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:mockito/mockito.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; +import 'package:waydowntown/models/answer.dart'; import 'package:waydowntown/models/region.dart'; import 'package:waydowntown/models/specification.dart'; import 'package:waydowntown/widgets/edit_specification_widget.dart'; @@ -94,6 +95,9 @@ void main() { bluetooth_collector: name: Bluetooth Collector instructions: Collect Bluetooth devices +code_collector: + name: Code Collector + instructions: Collect barcodes fill_in_the_blank: name: Fill in the Blank instructions: Fill in the blank @@ -366,8 +370,9 @@ another_concept: ); await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: EditSpecificationWidget( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( dio: dio, specification: specification, ), @@ -376,7 +381,9 @@ another_concept: await tester.pumpAndSettle(); - await tester.tap(find.text('Save')); + final saveButton = find.text('Save'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); await tester.pumpAndSettle(); expect(find.text('must be a known concept'), findsOneWidget); @@ -439,4 +446,663 @@ another_concept: tester.state>(find.byKey(const Key('region-dropdown'))); expect(regionDropdownState.value, equals('new_region')); }); + + testWidgets('EditSpecificationWidget displays existing answers', + (WidgetTester tester) async { + final specWithAnswers = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + answers: const [ + Answer( + id: 'a1', + label: 'Label 1', + answer: 'Answer text 1', + hint: 'Hint 1'), + Answer( + id: 'a2', + label: 'Label 2', + answer: 'Answer text 2', + hint: 'Hint 2'), + ], + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: specWithAnswers, + ), + ), + )); + + await tester.pumpAndSettle(); + + expect(find.text('Answers'), findsOneWidget); + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + expect(find.byKey(const Key('answer-card-1')), findsOneWidget); + + final answerField0 = + tester.widget(find.byKey(const Key('answer-text-0'))); + expect(answerField0.controller!.text, equals('Answer text 1')); + + final labelField0 = + tester.widget(find.byKey(const Key('answer-label-0'))); + expect(labelField0.controller!.text, equals('Label 1')); + + final hintField0 = + tester.widget(find.byKey(const Key('answer-hint-0'))); + expect(hintField0.controller!.text, equals('Hint 1')); + }); + + testWidgets('EditSpecificationWidget can add a new answer', + (WidgetTester tester) async { + final specWithNoAnswers = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + answers: const [], + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: specWithNoAnswers, + ), + ), + )); + + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsNothing); + + await tester.tap(find.byKey(const Key('add-answer'))); + await tester.pumpAndSettle(); + + // Sensor concept shows confirmation dialog + expect(find.text('Manual answer'), findsOneWidget); + await tester.tap(find.byKey(const Key('confirm-manual-answer'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + + await tester.enterText( + find.byKey(const Key('answer-text-0')), 'New answer'); + await tester.enterText( + find.byKey(const Key('answer-label-0')), 'New label'); + await tester.enterText( + find.byKey(const Key('answer-hint-0')), 'New hint'); + + final answerField = + tester.widget(find.byKey(const Key('answer-text-0'))); + expect(answerField.controller!.text, equals('New answer')); + }); + + testWidgets('EditSpecificationWidget can delete an unsaved answer', + (WidgetTester tester) async { + final specWithNoAnswers = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + answers: const [], + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: specWithNoAnswers, + ), + ), + )); + + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('add-answer'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-manual-answer'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + + await tester.tap(find.byKey(const Key('delete-answer-0'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsNothing); + }); + + testWidgets( + 'EditSpecificationWidget deletes existing answer via API and removes from list', + (WidgetTester tester) async { + final specWithAnswers = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + answers: const [ + Answer( + id: 'a1', + label: 'Label 1', + answer: 'Answer text 1', + hint: 'Hint 1'), + ], + ); + + dioAdapter.onDelete( + '/waydowntown/answers/a1', + (server) => server.reply(204, ''), + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: specWithAnswers, + ), + ), + )); + + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + + await tester.tap(find.byKey(const Key('delete-answer-0'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsNothing); + }); + + testWidgets( + 'EditSpecificationWidget saves new and existing answers on save', + (WidgetTester tester) async { + final specWithAnswers = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + answers: const [ + Answer( + id: 'a1', + label: 'Label 1', + answer: 'Answer text 1', + hint: 'Hint 1'), + ], + ); + + dioAdapter.onPatch( + '/waydowntown/specifications/spec1', + (server) => server.reply(200, { + 'data': {'id': 'spec1', 'type': 'specifications'} + }), + data: Matchers.any, + ); + + dioAdapter.onPatch( + '/waydowntown/answers/a1', + (server) => server.reply(200, { + 'data': { + 'id': 'a1', + 'type': 'answers', + 'attributes': { + 'answer': 'Updated answer', + 'label': 'Label 1', + 'hint': 'Hint 1', + 'order': 1, + } + } + }), + data: Matchers.any, + ); + + dioAdapter.onPost( + '/waydowntown/answers', + (server) => server.reply(201, { + 'data': { + 'id': 'a2', + 'type': 'answers', + 'attributes': { + 'answer': 'Brand new answer', + 'label': null, + 'hint': null, + 'order': 2, + } + } + }), + data: Matchers.any, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: specWithAnswers, + ), + ), + )); + + await tester.pumpAndSettle(); + + // Update existing answer + await tester.enterText( + find.byKey(const Key('answer-text-0')), 'Updated answer'); + + // Add a new answer + final addButton = find.byKey(const Key('add-answer')); + await tester.ensureVisible(addButton); + await tester.tap(addButton); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-manual-answer'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('answer-text-1')), 'Brand new answer'); + + // Save + final saveButton = find.text('Save'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'scan button appears for bluetooth_collector and code_collector concepts', + (WidgetTester tester) async { + final btSpec = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: btSpec, + ), + ), + )); + + await tester.pumpAndSettle(); + + final scanButton = find.byKey(const Key('scan-answers')); + await tester.ensureVisible(scanButton); + expect(scanButton, findsOneWidget); + + // Change concept to code_collector + await tester.tap(find.byType(DropdownButtonFormField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Code Collector').last); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('scan-answers'))); + expect(find.byKey(const Key('scan-answers')), findsOneWidget); + }); + + testWidgets( + 'scan button does not appear for non-sensor concepts', + (WidgetTester tester) async { + final spec = Specification( + id: 'spec1', + concept: 'fill_in_the_blank', + placed: false, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: spec, + ), + ), + )); + + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('scan-answers')), findsNothing); + expect(find.byKey(const Key('add-answer')), findsOneWidget); + }); + + testWidgets( + 'add-answer shows warning dialog for sensor concepts and not for others', + (WidgetTester tester) async { + final spec = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: spec, + ), + ), + )); + + await tester.pumpAndSettle(); + + // Tap add-answer for sensor concept — dialog should appear + await tester.ensureVisible(find.byKey(const Key('add-answer'))); + await tester.tap(find.byKey(const Key('add-answer'))); + await tester.pumpAndSettle(); + + expect(find.text('Manual answer'), findsOneWidget); + expect(find.text('This concept uses sensor input. Manually added answers may not correspond to real-world data.'), findsOneWidget); + + // Cancel — no answer should be added + await tester.tap(find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Cancel'), + )); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsNothing); + + // Tap add-answer again and confirm — answer should be added + await tester.tap(find.byKey(const Key('add-answer'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-manual-answer'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + + // Switch to a non-sensor concept + await tester.tap(find.byType(DropdownButtonFormField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fill in the Blank').last); + await tester.pumpAndSettle(); + + // Tap add-answer for non-sensor concept — no dialog, answer added directly + await tester.ensureVisible(find.byKey(const Key('add-answer'))); + await tester.tap(find.byKey(const Key('add-answer'))); + await tester.pumpAndSettle(); + + expect(find.text('Manual answer'), findsNothing); + expect(find.byKey(const Key('answer-card-1')), findsOneWidget); + }); + + testWidgets( + 'existing sensor-concept answers are locked and require confirmation to edit', + (WidgetTester tester) async { + final spec = Specification( + id: 'spec1', + concept: 'bluetooth_collector', + placed: false, + answers: [ + Answer(id: 'a1', answer: 'AA:BB:CC:DD:EE:FF', label: 'Device 1'), + ], + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: spec, + ), + ), + )); + + await tester.pumpAndSettle(); + + // Answer card should be visible with a lock icon + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + expect(find.byIcon(Icons.lock), findsOneWidget); + + // Tap on the locked answer text field — dialog should appear + await tester.tap(find.byKey(const Key('answer-text-0'))); + await tester.pumpAndSettle(); + + expect(find.text('Edit sensor answer'), findsOneWidget); + expect( + find.text( + 'This answer was produced by sensor input. Editing it may cause it to no longer correspond to real-world data.'), + findsOneWidget); + + // Cancel — lock icon should remain + await tester.tap(find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Cancel'), + )); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.lock), findsOneWidget); + + // Tap again and confirm + await tester.tap(find.byKey(const Key('answer-text-0'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-edit-answer'))); + await tester.pumpAndSettle(); + + // Lock icon should be gone + expect(find.byIcon(Icons.lock), findsNothing); + }); + + testWidgets( + 'existing non-sensor answers are not locked', + (WidgetTester tester) async { + final spec = Specification( + id: 'spec1', + concept: 'fill_in_the_blank', + placed: false, + answers: [ + Answer(id: 'a1', answer: 'hello', label: 'Greeting'), + ], + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + specification: spec, + ), + ), + )); + + await tester.pumpAndSettle(); + + // No lock icon for non-sensor concept + expect(find.byKey(const Key('answer-card-0')), findsOneWidget); + expect(find.byIcon(Icons.lock), findsNothing); + }); + + testWidgets('EditSpecificationWidget renders empty form in create mode', + (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + ), + ), + )); + + await tester.pumpAndSettle(); + + expect(find.text('New Specification'), findsOneWidget); + + final startField = + tester.widget(find.widgetWithText(TextField, 'Start Description')); + expect(startField.controller!.text, isEmpty); + + final taskField = + tester.widget(find.widgetWithText(TextField, 'Task Description')); + expect(taskField.controller!.text, isEmpty); + + final durationField = + tester.widget(find.widgetWithText(TextField, 'Duration (seconds)')); + expect(durationField.controller!.text, isEmpty); + + expect(find.byKey(const Key('answer-card-0')), findsNothing); + }); + + testWidgets('EditSpecificationWidget creates specification via POST', + (WidgetTester tester) async { + dioAdapter.onPost( + '/waydowntown/specifications', + (server) => server.reply(201, { + 'data': { + 'id': 'new-spec-id', + 'type': 'specifications', + 'attributes': { + 'concept': 'fill_in_the_blank', + 'task_description': 'A new task', + 'start_description': 'A new start', + 'duration': 60, + 'notes': '', + }, + } + }), + data: Matchers.any, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + ), + ), + )); + + await tester.pumpAndSettle(); + + // Select concept + await tester.tap(find.byType(DropdownButtonFormField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fill in the Blank').last); + await tester.pumpAndSettle(); + + // Fill in fields + await tester.enterText( + find.widgetWithText(TextField, 'Task Description'), 'A new task'); + await tester.enterText( + find.widgetWithText(TextField, 'Start Description'), 'A new start'); + await tester.tap(find.text('1m')); + + // Save + final saveButton = find.text('Save'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'EditSpecificationWidget in create mode saves answers with new spec ID', + (WidgetTester tester) async { + dioAdapter.onPost( + '/waydowntown/specifications', + (server) => server.reply(201, { + 'data': { + 'id': 'new-spec-id', + 'type': 'specifications', + 'attributes': { + 'concept': 'fill_in_the_blank', + 'task_description': 'A new task', + }, + } + }), + data: Matchers.any, + ); + + dioAdapter.onPost( + '/waydowntown/answers', + (server) => server.reply(201, { + 'data': { + 'id': 'new-answer-id', + 'type': 'answers', + 'attributes': { + 'answer': 'Test answer', + 'label': null, + 'hint': null, + 'order': 1, + } + } + }), + data: Matchers.any, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + ), + ), + )); + + await tester.pumpAndSettle(); + + // Select concept + await tester.tap(find.byType(DropdownButtonFormField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fill in the Blank').last); + await tester.pumpAndSettle(); + + // Fill task description + await tester.enterText( + find.widgetWithText(TextField, 'Task Description'), 'A new task'); + + // Add an answer + final addButton = find.byKey(const Key('add-answer')); + await tester.ensureVisible(addButton); + await tester.tap(addButton); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('answer-text-0')), 'Test answer'); + + // Save + final saveButton = find.text('Save'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + }); + + testWidgets('EditSpecificationWidget in create mode shows validation errors', + (WidgetTester tester) async { + dioAdapter.onPost( + '/waydowntown/specifications', + (server) => server.reply(422, { + 'errors': [ + { + 'source': {'pointer': '/data/attributes/concept'}, + 'detail': 'must be a known concept', + }, + { + 'source': {'pointer': '/data/attributes/task_description'}, + 'detail': "can't be blank", + }, + ], + }), + data: Matchers.any, + ); + + await tester.pumpWidget(MaterialApp( + home: DefaultAssetBundle( + bundle: testAssetBundle, + child: EditSpecificationWidget( + dio: dio, + ), + ), + )); + + await tester.pumpAndSettle(); + + final saveButton = find.text('Save'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + expect(find.text('must be a known concept'), findsOneWidget); + expect(find.text("can't be blank"), findsOneWidget); + }); } diff --git a/waydowntown_app/test/widgets/sensor_answer_scanner_test.dart b/waydowntown_app/test/widgets/sensor_answer_scanner_test.dart new file mode 100644 index 00000000..ec019885 --- /dev/null +++ b/waydowntown_app/test/widgets/sensor_answer_scanner_test.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:waydowntown/games/collector_game.dart'; +import 'package:waydowntown/widgets/sensor_answer_scanner.dart'; + +class MockStringDetector implements StringDetector { + final _controller = StreamController.broadcast(); + bool started = false; + bool disposed = false; + + @override + Stream get detectedStrings => _controller.stream; + + @override + void startDetecting() { + started = true; + } + + @override + void stopDetecting() {} + + @override + void dispose() { + disposed = true; + _controller.close(); + } + + void emit(String value) { + _controller.add(value); + } +} + +void main() { + testWidgets('detected items appear in the list', (tester) async { + final detector = MockStringDetector(); + List? result; + + await tester.pumpWidget(MaterialApp( + home: SensorAnswerScanner( + detector: detector, + inputBuilder: (_, __) => const SizedBox(), + title: 'Test Scanner', + ), + )); + + expect(find.text('Test Scanner'), findsOneWidget); + expect(find.text('Scanning...'), findsOneWidget); + expect(detector.started, isTrue); + + detector.emit('Device A'); + await tester.pump(); + + expect(find.text('Device A'), findsOneWidget); + expect(find.text('Scanning...'), findsNothing); + + detector.emit('Device B'); + await tester.pump(); + + expect(find.text('Device A'), findsOneWidget); + expect(find.text('Device B'), findsOneWidget); + }); + + testWidgets('duplicate values are deduplicated', (tester) async { + final detector = MockStringDetector(); + + await tester.pumpWidget(MaterialApp( + home: SensorAnswerScanner( + detector: detector, + inputBuilder: (_, __) => const SizedBox(), + title: 'Test Scanner', + ), + )); + + detector.emit('Device A'); + await tester.pump(); + detector.emit('Device A'); + await tester.pump(); + detector.emit('Device A'); + await tester.pump(); + + expect(find.text('Device A'), findsOneWidget); + }); + + testWidgets('toggling include and entering a hint', (tester) async { + final detector = MockStringDetector(); + + await tester.pumpWidget(MaterialApp( + home: SensorAnswerScanner( + detector: detector, + inputBuilder: (_, __) => const SizedBox(), + title: 'Test Scanner', + ), + )); + + detector.emit('Device A'); + await tester.pump(); + + // Hint field not visible before including + expect(find.byKey(const Key('hint-0')), findsNothing); + + // Toggle include + await tester.tap(find.byKey(const Key('include-0'))); + await tester.pump(); + + // Hint field is now visible + expect(find.byKey(const Key('hint-0')), findsOneWidget); + + await tester.enterText(find.byKey(const Key('hint-0')), 'Near entrance'); + await tester.pump(); + + // Toggle off + await tester.tap(find.byKey(const Key('include-0'))); + await tester.pump(); + + expect(find.byKey(const Key('hint-0')), findsNothing); + }); + + testWidgets('Done returns only included items as ScannedAnswer list', + (tester) async { + final detector = MockStringDetector(); + List? result; + + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + result = await Navigator.of(context).push>( + MaterialPageRoute( + builder: (context) => SensorAnswerScanner( + detector: detector, + inputBuilder: (_, __) => const SizedBox(), + title: 'Test Scanner', + ), + ), + ); + }, + child: const Text('Open Scanner'), + ), + ), + )); + + await tester.tap(find.text('Open Scanner')); + await tester.pumpAndSettle(); + + detector.emit('Device A'); + await tester.pump(); + detector.emit('Device B'); + await tester.pump(); + detector.emit('Device C'); + await tester.pump(); + + // List order is newest first: [C(0), B(1), A(2)] + expect(find.text('Device C'), findsOneWidget); + expect(find.text('Device B'), findsOneWidget); + expect(find.text('Device A'), findsOneWidget); + + // Include Device A (index 2) with hint + await tester.tap(find.byKey(const Key('include-2'))); + await tester.pump(); + await tester.enterText(find.byKey(const Key('hint-2')), 'Lobby'); + + // Include Device C (index 0) without hint + await tester.tap(find.byKey(const Key('include-0'))); + await tester.pump(); + + // Don't include Device B + + await tester.tap(find.byKey(const Key('scanner-done'))); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.length, equals(2)); + + // _buildResult iterates _entries in order: [C, B, A], filtering included + // Device A is included first in iteration (index 2), Device C second (index 0) + // Actually entries list is [C, B, A], so iteration yields A first? No. + // _entries order: insert(0,...) so C is at 0, B at 1, A at 2 + // .where(included) iterates: C(included), B(not), A(included) => [C, A] + expect(result![0].answer, equals('Device C')); + expect(result![0].hint, isNull); + expect(result![1].answer, equals('Device A')); + expect(result![1].hint, equals('Lobby')); + }); +}