diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 7401865..a83d2e7 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -250,6 +250,19 @@ public sealed class CcsCaseTests Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal); } + [Fact] + public void TopicInterviewRepairsLegacyOpeningCompletionFlag() + { + var definition = ValidImport().Cases[0]; + definition.InitialInterview = TopicInterview(); + var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = true }; + + CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow); + + Assert.False(attempt.OpeningCompleted); + Assert.Equal("chief_concern", attempt.InterviewNodeId); + } + [Fact] public void TopicInterviewRejectsPrerequisiteCycles() { diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 128192f..e3c30d9 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -436,6 +436,9 @@ public sealed class CcsStore : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }]; CcsInterviewStateResponse? interview = null; var currentInterviewNode = CurrentInterviewNode(attempt, definition); + var openingCompleted = attempt.OpeningCompleted ?? true; + if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true) + openingCompleted = false; if (currentInterviewNode is not null) { interview = new CcsInterviewStateResponse @@ -443,7 +446,7 @@ public sealed class CcsStore CurrentNodeId = currentInterviewNode.Id, CurrentNode = currentInterviewNode, VisitedChoiceIds = attempt.InterviewChoiceIds, - Completed = attempt.OpeningCompleted ?? false + Completed = openingCompleted }; } return new CcsAttemptResponse @@ -452,7 +455,7 @@ public sealed class CcsStore CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}", Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision, SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc, - OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location, + OpeningCompleted = openingCompleted, Location = attempt.Location, DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, @@ -482,12 +485,18 @@ public sealed class CcsStore public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now) { - if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete."); if (definition.InitialInterview.Topics.Count > 0) { + if (attempt.OpeningCompleted == true + && attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The initial interview is already complete."); + // Attempts that passed through the legacy classic opening can have + // this flag set before any graphical topic was completed. + attempt.OpeningCompleted = false; ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now); return; } + if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete."); var interview = EffectiveInterview(definition); var node = interview.Nodes.FirstOrDefault(value => value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)) diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index 59f6ead..65ec83d 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -102,7 +102,8 @@ func _render_attempt(attempt: Dictionary) -> void: if node.is_empty(): _show_unsupported_attempt("The graphical interview could not load its current dialogue.") return - if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)): + if str(attempt.get("experienceMode", "management")) != "graphical_interview" \ + and bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)): _show_unsupported_attempt("This case has progressed beyond the graphical interview.") return _show_doctor_office(node, attempt) diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 0f5ff4f..8aeef0c 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -62,6 +62,13 @@ func _run() -> void: _assert(not screen.active_scene.is_patient_dialogue_visible(), "the opening should wait for a clinician dialogue selection") _assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices") _assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio") + var stale_graphical_attempt := _sample_attempt(false) + stale_graphical_attempt.experienceMode = "graphical_interview" + stale_graphical_attempt.openingCompleted = true + service.attempt = stale_graphical_attempt + screen._render_state() + await process_frame + _assert(screen.current_view == "doctor_office", "legacy completion flags should not block a graphical interview") service.attempt = _sample_branching_attempt() screen._render_state() await process_frame