From f12904bf739285446a923a522dcf379928868ea2 Mon Sep 17 00:00:00 2001 From: hz Date: Sun, 19 Jul 2026 21:28:55 -0500 Subject: [PATCH] Unify CCS case management flow --- api/QuestionsApi.Tests/CcsCaseTests.cs | 15 + api/QuestionsApi/Controllers/CcsController.cs | 17 +- api/QuestionsApi/Models/CcsModels.cs | 9 + api/QuestionsApi/Services/CcsStore.cs | 108 ++- scripts/autoload/ccs_service.gd | 29 +- scripts/ui/ccs_history.gd | 2 +- scripts/ui/ccs_hub.gd | 716 +++++++++++++++++- scripts/ui/ccs_player.gd | 2 +- scripts/ui/ccs_review.gd | 2 +- tests/ccs_hub_test.gd | 26 + tests/ccs_hub_test.gd.uid | 1 + tests/ccs_hub_ui_test.gd | 57 ++ tests/ccs_hub_ui_test.gd.uid | 1 + 13 files changed, 938 insertions(+), 47 deletions(-) create mode 100644 tests/ccs_hub_test.gd create mode 100644 tests/ccs_hub_test.gd.uid create mode 100644 tests/ccs_hub_ui_test.gd create mode 100644 tests/ccs_hub_ui_test.gd.uid diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 42856db..81b6e00 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -1,12 +1,27 @@ using QuestionsApi.Models; using QuestionsApi.Services; using System.Text.Json; +using MongoDB.Bson; using Xunit; namespace QuestionsApi.Tests; public sealed class CcsCaseTests { + [Fact] + public void CcsHistoryCursorRoundTripsTimestampAndId() + { + var createdUtc = new DateTime(2026, 7, 19, 12, 34, 56, DateTimeKind.Utc); + var id = ObjectId.GenerateNewId().ToString(); + + var encoded = CcsStore.EncodeHistoryCursor(createdUtc, id); + + Assert.True(CcsStore.TryDecodeHistoryCursor(encoded, out var decoded)); + Assert.Equal(createdUtc, decoded!.CreatedUtc); + Assert.Equal(id, decoded.Id); + Assert.False(CcsStore.TryDecodeHistoryCursor("not-a-cursor", out _)); + } + [Fact] public void AppFeedbackValidationAcceptsSafeDiagnostics() { diff --git a/api/QuestionsApi/Controllers/CcsController.cs b/api/QuestionsApi/Controllers/CcsController.cs index ba8ef2c..2e6c76f 100644 --- a/api/QuestionsApi/Controllers/CcsController.cs +++ b/api/QuestionsApi/Controllers/CcsController.cs @@ -27,6 +27,13 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont catch (InvalidOperationException exception) { return Conflict(exception.Message); } } + [HttpGet("active")] + public async Task Active() + { + var attempt = await store.FindActiveAsync(UserId()); + return attempt is null ? NoContent() : Ok(await store.ToResponseAsync(attempt)); + } + [HttpGet("attempts/{id}")] public async Task Attempt(string id) { @@ -54,7 +61,15 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont } [HttpGet("history")] - public async Task History([FromQuery] int limit = 50) => Ok(await store.HistoryAsync(UserId(), limit)); + public async Task History( + [FromQuery] int limit = 20, + [FromQuery] string status = "all", + [FromQuery] string sort = "newest", + [FromQuery] string? cursor = null) + { + try { return Ok(await store.HistoryAsync(UserId(), limit, status, sort, cursor)); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + } [HttpGet("prediction")] public async Task Prediction() => Ok(await store.PredictionAsync(UserId())); diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index 3b982aa..f920171 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -107,6 +107,7 @@ public class CcsAttempt { [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } public string UserId { get; set; } = ""; + [BsonIgnoreIfNull] public string? ActiveSlot { get; set; } public string CaseExternalId { get; set; } = ""; [BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = ""; public int CaseVersion { get; set; } @@ -228,6 +229,8 @@ public class CcsAttemptResponse public string Location { get; set; } = ""; public DateTime? DeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; } + public DateTime CreatedUtc { get; set; } + public int RealTimeMinutes { get; set; } public int ElapsedSeconds { get; set; } public string PatientSummary { get; set; } = ""; public string ReasonForEncounter { get; set; } = ""; @@ -256,6 +259,12 @@ public class CcsHistoryItem public DateTime CreatedUtc { get; set; } } +public class CcsHistoryPage +{ + public List Items { get; set; } = []; + public string? NextCursor { get; set; } +} + public class CcsUserRating { [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 0fad430..d938022 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -1,5 +1,7 @@ +using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; +using System.Text; namespace QuestionsApi.Services; @@ -29,6 +31,9 @@ public sealed class CcsStore Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc))); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc))); + _attempts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.ActiveSlot), + new CreateIndexOptions { Unique = true, Sparse = true, Name = "unique_active_ccs_attempt" })); } public async Task ImportAsync(CcsImportRequest request) @@ -86,6 +91,8 @@ public sealed class CcsStore { var validation = CcsCaseValidator.ValidateCreate(request); if (validation is not null) throw new ArgumentException(validation); + if (await FindActiveAsync(userId) is not null) + throw new InvalidOperationException("An active CCS case already exists."); CcsCaseRevision? definition; if (string.IsNullOrWhiteSpace(request.CaseExternalId)) { @@ -104,6 +111,7 @@ public sealed class CcsStore var attempt = new CcsAttempt { UserId = userId, + ActiveSlot = userId, CaseExternalId = definition.ExternalId, CaseRevisionId = definition.Id ?? "", CaseVersion = definition.Version, @@ -119,10 +127,45 @@ public sealed class CcsStore new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance } ] }; - await _attempts.InsertOneAsync(attempt); + try { await _attempts.InsertOneAsync(attempt); } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + throw new InvalidOperationException("An active CCS case already exists.", exception); + } return attempt; } + public async Task FindActiveAsync(string userId) + { + var unfinished = await _attempts.Find(value => value.UserId == userId && + (value.State == CcsAttemptStates.Active || value.State == CcsAttemptStates.FinalOrders)) + .SortByDescending(value => value.UpdatedUtc).ThenByDescending(value => value.Id).ToListAsync(); + if (unfinished.Count == 0) return null; + + var retained = unfinished[0]; + foreach (var older in unfinished.Skip(1)) + { + older.State = CcsAttemptStates.Abandoned; + older.ActiveSlot = null; + older.UpdatedUtc = DateTime.UtcNow; + await SaveAsync(older); + } + + retained = await GetOwnedAsync(retained.Id ?? "", userId) ?? retained; + if (retained.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned) return null; + if (retained.ActiveSlot != userId) + { + retained.ActiveSlot = userId; + retained.UpdatedUtc = DateTime.UtcNow; + try { await SaveAsync(retained); } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return await _attempts.Find(value => value.ActiveSlot == userId).FirstOrDefaultAsync(); + } + } + return retained; + } + public async Task GetOwnedAsync(string id, string userId) { var attempt = await _attempts.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync(); @@ -177,19 +220,71 @@ public sealed class CcsStore return attempt; } - public async Task> HistoryAsync(string userId, int limit) + public async Task HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null) { - var attempts = await _attempts.Find(value => value.UserId == userId).SortByDescending(value => value.CreatedUtc).Limit(Math.Clamp(limit, 1, 100)).ToListAsync(); + limit = Math.Clamp(limit, 1, 50); + status = status.Trim().ToLowerInvariant(); + sort = sort.Trim().ToLowerInvariant(); + if (status is not ("all" or "completed" or "abandoned")) throw new ArgumentException("The CCS history status is invalid."); + if (sort is not ("newest" or "oldest")) throw new ArgumentException("The CCS history sort order is invalid."); + + var filters = Builders.Filter; + var filter = filters.Eq(value => value.UserId, userId); + filter &= status switch + { + "completed" => filters.Eq(value => value.State, CcsAttemptStates.Completed), + "abandoned" => filters.Eq(value => value.State, CcsAttemptStates.Abandoned), + _ => filters.In(value => value.State, [CcsAttemptStates.Completed, CcsAttemptStates.Abandoned]) + }; + if (!string.IsNullOrWhiteSpace(cursor)) + { + if (!TryDecodeHistoryCursor(cursor, out var decoded)) throw new ArgumentException("The CCS history cursor is invalid."); + var dateComparison = sort == "newest" ? filters.Lt(value => value.CreatedUtc, decoded!.CreatedUtc) : filters.Gt(value => value.CreatedUtc, decoded!.CreatedUtc); + var idComparison = sort == "newest" ? filters.Lt(value => value.Id, decoded!.Id) : filters.Gt(value => value.Id, decoded!.Id); + filter &= dateComparison | (filters.Eq(value => value.CreatedUtc, decoded!.CreatedUtc) & idComparison); + } + var ordering = sort == "newest" + ? Builders.Sort.Descending(value => value.CreatedUtc).Descending(value => value.Id) + : Builders.Sort.Ascending(value => value.CreatedUtc).Ascending(value => value.Id); + var attempts = await _attempts.Find(filter).Sort(ordering).Limit(limit + 1).ToListAsync(); + var hasMore = attempts.Count > limit; + if (hasMore) attempts.RemoveAt(attempts.Count - 1); var revisionIds = attempts.Select(value => value.CaseRevisionId).Distinct().ToList(); var definitions = (await _cases.Find(value => revisionIds.Contains(value.Id!)).ToListAsync()).ToDictionary(value => value.Id ?? ""); - return attempts.Select(value => new CcsHistoryItem + var items = attempts.Select(value => new CcsHistoryItem { Id = value.Id ?? "", CaseExternalId = value.CaseExternalId, - CaseTitle = definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId, + CaseTitle = value.State == CcsAttemptStates.Completed + ? definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId + : "Timed CCS Case", Mode = value.Mode, State = value.State, Score = value.Score?.Percent, Rating = value.Rating, CreatedUtc = value.CreatedUtc }).ToList(); + var last = attempts.LastOrDefault(); + return new CcsHistoryPage + { + Items = items, + NextCursor = hasMore && last is not null ? EncodeHistoryCursor(last.CreatedUtc, last.Id ?? "") : null + }; } + public static string EncodeHistoryCursor(DateTime createdUtc, string id) => + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createdUtc.ToUniversalTime().Ticks}|{id}")); + + public static bool TryDecodeHistoryCursor(string value, out HistoryCursor? cursor) + { + cursor = null; + try + { + var parts = Encoding.UTF8.GetString(Convert.FromBase64String(value)).Split('|', 2); + if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !ObjectId.TryParse(parts[1], out _)) return false; + cursor = new HistoryCursor(new DateTime(ticks, DateTimeKind.Utc), parts[1]); + return true; + } + catch (Exception exception) when (exception is FormatException or ArgumentOutOfRangeException) { return false; } + } + + public sealed record HistoryCursor(DateTime CreatedUtc, string Id); + public async Task PredictionAsync(string userId) { var timed = await _attempts.Find(value => value.UserId == userId && value.Mode == CcsAttemptModes.Timed && value.State == CcsAttemptStates.Completed && value.Score != null) @@ -226,6 +321,7 @@ public sealed class CcsStore Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision, SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location, DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, + CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)), PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter, Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History, @@ -241,6 +337,7 @@ public sealed class CcsStore public async Task AbandonAsync(CcsAttempt attempt) { attempt.State = CcsAttemptStates.Abandoned; + attempt.ActiveSlot = null; attempt.UpdatedUtc = DateTime.UtcNow; await SaveAsync(attempt); } @@ -346,6 +443,7 @@ public sealed class CcsStore if (attempt.State == CcsAttemptStates.Completed) return; var definition = await DefinitionAsync(attempt); attempt.State = CcsAttemptStates.Completed; + attempt.ActiveSlot = null; attempt.CompletedUtc = DateTime.UtcNow; attempt.Score = CalculateScore(attempt, definition); attempt.Rating = await _ratings.ApplyAsync(attempt); diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index d332f77..e3912d8 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -24,6 +24,7 @@ var cases: Array = [] var attempt := {} var orders: Array = [] var history: Array = [] +var history_next_cursor := "" var prediction := {} var rating := {"elo": 1000, "ratedCaseCount": 0} var is_loading := false @@ -50,6 +51,10 @@ func create_attempt(case_external_id: String, mode: String) -> void: _send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": mode}) +func load_active() -> void: + _send("active", HTTPClient.METHOD_GET, "/active") + + func load_attempt(attempt_id: String) -> void: _send("attempt", HTTPClient.METHOD_GET, "/attempts/%s" % attempt_id.uri_encode()) @@ -74,8 +79,11 @@ func search_orders(query: String) -> void: _send("orders", HTTPClient.METHOD_GET, "/orders/search?q=%s&limit=20" % query.strip_edges().uri_encode()) -func load_history() -> void: - _send("history", HTTPClient.METHOD_GET, "/history?limit=50") +func load_history(cursor: String = "", status: String = "all", sort_order: String = "newest") -> void: + var suffix := "/history?limit=20&status=%s&sort=%s" % [status.uri_encode(), sort_order.uri_encode()] + if not cursor.is_empty(): + suffix += "&cursor=%s" % cursor.uri_encode() + _send("history_append" if not cursor.is_empty() else "history", HTTPClient.METHOD_GET, suffix) func load_prediction() -> void: @@ -184,16 +192,21 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri else: attempt_changed.emit() return var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) - if action in ["cases", "orders", "history"] and typeof(parsed) != TYPE_ARRAY: + if action in ["cases", "orders"] and typeof(parsed) != TYPE_ARRAY: request_failed.emit("The CCS API returned an invalid list.") return - if action not in ["cases", "orders", "history"] and typeof(parsed) != TYPE_DICTIONARY: + if action not in ["cases", "orders"] and typeof(parsed) != TYPE_DICTIONARY: request_failed.emit("The CCS API returned an invalid response.") return match action: "cases": cases = parsed; cases_changed.emit() "orders": orders = parsed; orders_changed.emit() - "history": history = parsed; history_changed.emit() + "history", "history_append": + var items: Array = parsed.get("items", []) + history = history + items if action == "history_append" else items + var cursor_value: Variant = parsed.get("nextCursor", null) + history_next_cursor = "" if cursor_value == null else str(cursor_value) + history_changed.emit() "prediction": prediction = parsed; prediction_changed.emit() "rating": rating = parsed; rating_changed.emit() "report": report_submitted.emit(str(parsed.get("id", ""))) @@ -218,3 +231,9 @@ func _on_auth_changed() -> void: if not AuthState.is_logged_in: highlights.clear() text_scale = 1.0 + attempt = {} + orders = [] + history = [] + history_next_cursor = "" + prediction = {} + rating = {"elo": 1000, "ratedCaseCount": 0} diff --git a/scripts/ui/ccs_history.gd b/scripts/ui/ccs_history.gd index da0dd56..ffba384 100644 --- a/scripts/ui/ccs_history.gd +++ b/scripts/ui/ccs_history.gd @@ -19,7 +19,7 @@ func _ready() -> void: CCSService.rating_changed.connect(_render_rating) func on_show(_payload: Variant = null) -> void: - CCSService.load_history(); CCSService.load_prediction(); CCSService.load_rating() + main.show_screen("ccs_hub") func _render() -> void: for child in list.get_children(): child.queue_free() diff --git a/scripts/ui/ccs_hub.gd b/scripts/ui/ccs_hub.gd index 55a1c5f..e791a04 100644 --- a/scripts/ui/ccs_hub.gd +++ b/scripts/ui/ccs_hub.gd @@ -1,48 +1,698 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") +const CASE_ICON := preload("res://assets/icons/lucide/stethoscope.svg") +const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg") + var main: Control -var status: Label -var start_button: Button +var page: VBoxContainer +var active_section: PanelContainer +var active_content: VBoxContainer +var history_section: PanelContainer +var history_header: BoxContainer +var table_header: HBoxContainer +var history_list: VBoxContainer +var status_label: Label +var load_more_button: Button +var filter_option: OptionButton +var sort_option: OptionButton +var abandon_dialog: ConfirmationDialog +var pending_action := "" +var pending_attempt_id := "" +var history_status := "all" +var history_sort := "newest" +var compact_layout := false + func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) - add_theme_constant_override("separation", 14) - var back := Button.new(); back.text = "< Step 3 modes"; back.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.style_chip(back, Style.CYAN); back.pressed.connect(func(): main.show_screen("selection", "step3")); add_child(back) - var title := Label.new(); title.text = "CCS Cases"; Style.set_label(title, 34, Style.TEXT); add_child(title) - var note := Label.new(); note.text = "Manage a simulated patient through examination, orders, treatment, location, and time."; Style.set_label(note, 16, Style.MUTED); add_child(note) - status = Label.new(); Style.set_label(status, 15, Style.CYAN); add_child(status) - start_button = Button.new() - start_button.text = "Start Timed Case" - start_button.custom_minimum_size = Vector2(280, 64) - start_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.style_primary_button(start_button) - start_button.pressed.connect(_start) - add_child(start_button) - var spacer := Control.new(); spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(spacer) - var history := Button.new(); history.text = "Case History"; history.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.style_chip(history, Style.CYAN); history.pressed.connect(func(): main.show_screen("ccs_history")); add_child(history) - CCSService.cases_changed.connect(_render) - CCSService.attempt_changed.connect(_attempt_ready) - CCSService.request_failed.connect(_on_request_failed) + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 0) + _build_ui() + _connect_services() + resized.connect(_update_responsive_layout) + UIThemeService.theme_changed.connect(func(_dark_mode: bool): refresh_theme_controls()) + call_deferred("_update_responsive_layout") + + +func _build_ui() -> void: + var scroll := ScrollContainer.new() + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + + page = VBoxContainer.new() + page.size_flags_horizontal = Control.SIZE_EXPAND_FILL + page.add_theme_constant_override("separation", 18) + scroll.add_child(page) + + active_section = PanelContainer.new() + active_section.name = "ActiveCcsSection" + page.add_child(active_section) + active_content = VBoxContainer.new() + active_content.add_theme_constant_override("separation", 10) + active_section.add_child(active_content) + + history_section = PanelContainer.new() + history_section.name = "CcsHistorySection" + history_section.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(history_section) + var history_content := VBoxContainer.new() + history_content.add_theme_constant_override("separation", 12) + history_section.add_child(history_content) + + history_header = BoxContainer.new() + history_header.add_theme_constant_override("separation", 10) + history_content.add_child(history_header) + var heading := Label.new() + heading.text = "Case History" + heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL + Brand.apply_font(heading) + heading.add_theme_font_size_override("font_size", 21) + history_header.add_child(heading) + + filter_option = OptionButton.new() + filter_option.name = "HistoryFilter" + filter_option.custom_minimum_size = Vector2(175, 42) + filter_option.add_item("All Cases") + filter_option.set_item_metadata(0, "all") + filter_option.add_item("Completed") + filter_option.set_item_metadata(1, "completed") + filter_option.add_item("Abandoned") + filter_option.set_item_metadata(2, "abandoned") + filter_option.item_selected.connect(_on_filter_selected) + history_header.add_child(filter_option) + + sort_option = OptionButton.new() + sort_option.name = "HistorySort" + sort_option.custom_minimum_size = Vector2(165, 42) + sort_option.add_item("Newest First") + sort_option.set_item_metadata(0, "newest") + sort_option.add_item("Oldest First") + sort_option.set_item_metadata(1, "oldest") + sort_option.item_selected.connect(_on_sort_selected) + history_header.add_child(sort_option) + + status_label = Label.new() + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Brand.apply_font(status_label) + status_label.add_theme_font_size_override("font_size", 15) + history_content.add_child(status_label) + + table_header = _build_table_header() + history_content.add_child(table_header) + history_list = VBoxContainer.new() + history_list.add_theme_constant_override("separation", 0) + history_content.add_child(history_list) + + load_more_button = Button.new() + load_more_button.text = "Show more" + load_more_button.visible = false + load_more_button.custom_minimum_size = Vector2(150, 42) + load_more_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + load_more_button.pressed.connect(_load_more) + history_content.add_child(load_more_button) + + abandon_dialog = ConfirmationDialog.new() + abandon_dialog.title = "Abandon case?" + abandon_dialog.dialog_text = "This case cannot be resumed and will not receive a score. It will remain visible in your case history." + abandon_dialog.ok_button_text = "Abandon Case" + abandon_dialog.cancel_button_text = "Keep Case" + abandon_dialog.confirmed.connect(_confirm_abandon) + add_child(abandon_dialog) + refresh_theme_controls() + + +func _connect_services() -> void: + if not CCSService.cases_changed.is_connected(_render_active_section): + CCSService.cases_changed.connect(_render_active_section) + if not CCSService.attempt_changed.is_connected(_on_attempt_changed): + CCSService.attempt_changed.connect(_on_attempt_changed) + if not CCSService.attempt_abandoned.is_connected(_on_attempt_abandoned): + CCSService.attempt_abandoned.connect(_on_attempt_abandoned) + if not CCSService.history_changed.is_connected(_render_history): + CCSService.history_changed.connect(_render_history) + if not CCSService.prediction_changed.is_connected(_render_active_section): + CCSService.prediction_changed.connect(_render_active_section) + if not CCSService.rating_changed.is_connected(_render_active_section): + CCSService.rating_changed.connect(_render_active_section) + if not CCSService.request_failed.is_connected(_on_request_failed): + CCSService.request_failed.connect(_on_request_failed) + func on_show(_payload: Variant = null) -> void: - status.text = "Loading cases..." + pending_action = "" + pending_attempt_id = "" + status_label.text = "Loading case history..." + load_more_button.disabled = true CCSService.load_cases() + CCSService.load_active() + CCSService.load_prediction() + CCSService.load_rating() + _load_history_page() -func _render() -> void: - status.text = "No published cases are available yet." if CCSService.cases.is_empty() else "%d cases available" % CCSService.cases.size() - start_button.disabled = CCSService.cases.is_empty() -func _start() -> void: - status.text = "Starting case..." - start_button.disabled = true - CCSService.create_attempt("", "timed") +func refresh_theme_controls() -> void: + if page == null: + return + var dark := UIThemeService.is_dark_mode() + active_section.add_theme_stylebox_override("panel", _section_box(dark)) + history_section.add_theme_stylebox_override("panel", _section_box(dark)) + status_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + _style_option(filter_option, dark) + _style_option(sort_option, dark) + _style_outline_button(load_more_button, Brand.PURPLE, dark) + _render_active_section() + _render_history() + + +func _render_active_section() -> void: + if active_content == null: + return + _clear(active_content) + var dark := UIThemeService.is_dark_mode() + var has_active := has_active_case(CCSService.attempt) + var section_title := Label.new() + section_title.text = "Active Case" if has_active else "Timed CCS Case" + Brand.apply_font(section_title) + section_title.add_theme_font_size_override("font_size", 18) + section_title.add_theme_color_override("font_color", Color("9b73ff") if dark else Brand.PURPLE) + active_content.add_child(section_title) + + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", _active_card_box(dark)) + active_content.add_child(card) + var card_layout := BoxContainer.new() + card_layout.name = "ActiveCardLayout" + card_layout.vertical = compact_layout + card_layout.add_theme_constant_override("separation", 18) + card.add_child(card_layout) + + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(94, 94) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.TEAL, 8)) + card_layout.add_child(icon_panel) + var icon := TextureRect.new() + icon.texture = CASE_ICON + icon.custom_minimum_size = Vector2(44, 44) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.modulate = Brand.TEAL + icon_panel.add_child(icon) + + var details := VBoxContainer.new() + details.size_flags_horizontal = Control.SIZE_EXPAND_FILL + details.add_theme_constant_override("separation", 5) + card_layout.add_child(details) + var heading := Label.new() + heading.text = "Timed CCS Case" + Brand.apply_font(heading) + heading.add_theme_font_size_override("font_size", 19) + heading.add_theme_color_override("font_color", Brand.text_color(dark)) + details.add_child(heading) + if has_active: + _build_active_details(details, dark) + else: + _build_start_details(details, dark) + + var actions := VBoxContainer.new() + actions.name = "ActiveActions" + if not compact_layout: + actions.custom_minimum_size = Vector2(265, 0) + actions.size_flags_vertical = Control.SIZE_SHRINK_CENTER + actions.add_theme_constant_override("separation", 10) + card_layout.add_child(actions) + var primary := Button.new() + primary.name = "PrimaryCcsAction" + primary.text = "Resume Case" if has_active else "Start Case" + primary.custom_minimum_size = Vector2(0, 48) + primary.disabled = not pending_action.is_empty() or (not has_active and CCSService.cases.is_empty()) + _style_gradient_button(primary) + primary.pressed.connect(_launch_or_resume) + actions.add_child(primary) + if has_active: + var abandon := Button.new() + abandon.name = "AbandonCase" + abandon.text = "Abandon Case" + abandon.custom_minimum_size = Vector2(0, 46) + _style_outline_button(abandon, Color("ff5f76"), dark) + abandon.pressed.connect(func(): abandon_dialog.popup_centered(Vector2i(520, 220))) + actions.add_child(abandon) + + _build_performance_summary(dark) + var notice := PanelContainer.new() + notice.add_theme_stylebox_override("panel", _notice_box(dark)) + active_content.add_child(notice) + var notice_label := Label.new() + notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + notice_label.text = "Abandoning ends this case without a score; the attempt remains in case history." if has_active else _availability_message() + _style_muted_label(notice_label, dark, 14) + notice.add_child(notice_label) + + +func _build_active_details(details: VBoxContainer, dark: bool) -> void: + var attempt: Dictionary = CCSService.attempt + var started := Label.new() + started.text = "Started: %s" % _format_local_datetime(str(attempt.get("createdUtc", ""))) + _style_muted_label(started, dark, 14) + details.add_child(started) + var state := Label.new() + var state_text := "Final orders" if str(attempt.get("state", "")) == "final_orders" else "In progress" + state.text = "%s | %s | %s" % [state_text, humanize(str(attempt.get("location", ""))), remaining_time_text(attempt)] + _style_muted_label(state, dark, 14) + details.add_child(state) + + +func _build_start_details(details: VBoxContainer, dark: bool) -> void: + var copy := Label.new() + copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + copy.text = "Manage a randomly selected patient through focused examination, diagnostic orders, treatment, disposition, and simulated time." + _style_muted_label(copy, dark, 15) + details.add_child(copy) + + +func _build_performance_summary(dark: bool) -> void: + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", _notice_box(dark)) + active_content.add_child(panel) + var row := BoxContainer.new() + row.vertical = compact_layout + row.add_theme_constant_override("separation", 18) + panel.add_child(row) + var rating_value := "%d" % int(CCSService.rating.get("elo", 1000)) + var rating_detail := "%d rated cases" % int(CCSService.rating.get("ratedCaseCount", 0)) + _add_metric(row, "CCS Rating", rating_value, rating_detail, dark) + var ccs_score: Variant = CCSService.prediction.get("ccsScore", null) + var score_value := "--" + if ccs_score != null: + score_value = "%d%%" % int(round(float(ccs_score))) + _add_metric(row, "CCS Performance", score_value, "%d contributing cases" % int(CCSService.prediction.get("contributingCases", 0)), dark) + var combined: Variant = CCSService.prediction.get("combinedPredictedScore", null) + var prediction_value := "--" + var prediction_detail := "Complete a Day 1 simulation" + if combined != null: + prediction_value = "%d" % int(combined) + prediction_detail = "Range %d-%d" % [int(CCSService.prediction.get("rangeLow", 0)), int(CCSService.prediction.get("rangeHigh", 0))] + _add_metric(row, "Combined Prediction", prediction_value, prediction_detail, dark) + + +func _add_metric(parent: BoxContainer, caption: String, value: String, detail: String, dark: bool) -> void: + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + parent.add_child(column) + var caption_label := Label.new() + caption_label.text = caption + _style_muted_label(caption_label, dark, 12) + column.add_child(caption_label) + var value_label := Label.new() + value_label.text = value + Brand.apply_font(value_label) + value_label.add_theme_font_size_override("font_size", 22) + value_label.add_theme_color_override("font_color", Brand.text_color(dark)) + column.add_child(value_label) + var detail_label := Label.new() + detail_label.text = detail + _style_muted_label(detail_label, dark, 12) + column.add_child(detail_label) + + +func _render_history() -> void: + if history_list == null or not visible: + return + _clear(history_list) + var items: Array = CCSService.history + status_label.text = "No cases match this filter." if items.is_empty() else "" + for raw_item in items: + var item: Dictionary = raw_item + history_list.add_child(_build_history_row(item)) + load_more_button.visible = not CCSService.history_next_cursor.is_empty() + load_more_button.disabled = false + table_header.visible = not compact_layout and not items.is_empty() + + +func _build_table_header() -> HBoxContainer: + var dark := UIThemeService.is_dark_mode() + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + for definition in [["Case", 2.0], ["Date", 1.15], ["Status", 1.0], ["Score", 0.8], ["CCS Rating", 1.0], ["Actions", 1.0]]: + var label := Label.new() + label.text = str(definition[0]) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_stretch_ratio = float(definition[1]) + Brand.apply_font(label) + label.add_theme_font_size_override("font_size", 13) + label.add_theme_color_override("font_color", Brand.muted_color(dark)) + row.add_child(label) + return row + + +func _build_history_row(item: Dictionary) -> Control: + var dark := UIThemeService.is_dark_mode() + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", _row_box(dark)) + var state := str(item.get("state", "")) + if compact_layout: + var card := VBoxContainer.new() + card.add_theme_constant_override("separation", 8) + panel.add_child(card) + var top := HBoxContainer.new() + card.add_child(top) + top.add_child(_case_cell(item, dark)) + top.add_child(_status_badge(state, dark)) + var metrics := Label.new() + metrics.text = "%s | %s | %s" % [_format_local_datetime(str(item.get("createdUtc", ""))).replace("\n", " "), _score_text(item), _rating_text(item)] + _style_muted_label(metrics, dark, 14) + card.add_child(metrics) + if state == "completed": + card.add_child(_review_button(item, dark)) + return panel + + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(0, 66) + row.add_theme_constant_override("separation", 10) + panel.add_child(row) + _add_table_cell(row, _case_cell(item, dark), 2.0) + _add_table_cell(row, _plain_cell(_format_local_datetime(str(item.get("createdUtc", ""))), dark), 1.15) + _add_table_cell(row, _status_badge(state, dark), 1.0) + _add_table_cell(row, _plain_cell(_score_text(item), dark), 0.8) + _add_table_cell(row, _plain_cell(_rating_text(item), dark), 1.0) + var action: Control = _plain_cell("-", dark) + if state == "completed": + action = _review_button(item, dark) + _add_table_cell(row, action, 1.0) + return panel + + +func _case_cell(item: Dictionary, dark: bool) -> Control: + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var title := Label.new() + title.text = str(item.get("caseTitle", "CCS Case")) + title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Brand.apply_font(title) + title.add_theme_font_size_override("font_size", 15) + title.add_theme_color_override("font_color", Brand.text_color(dark)) + column.add_child(title) + var mode := Label.new() + mode.text = "Timed case" + _style_muted_label(mode, dark, 12) + column.add_child(mode) + return column + + +func _plain_cell(value: String, dark: bool) -> Label: + var label := Label.new() + label.text = value + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Brand.apply_font(label) + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override("font_color", Brand.text_color(dark)) + return label + + +func _status_badge(state: String, dark: bool) -> Label: + var label := Label.new() + var accent := Brand.SUCCESS if state == "completed" else Brand.SLATE + label.text = "Completed" if state == "completed" else "Abandoned" + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.size_flags_vertical = Control.SIZE_SHRINK_CENTER + Brand.apply_font(label) + label.add_theme_font_size_override("font_size", 12) + label.add_theme_color_override("font_color", accent if dark else accent.darkened(0.2)) + label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent)) + return label + + +func _review_button(item: Dictionary, dark: bool) -> Button: + var button := Button.new() + button.text = "Review" + button.icon = REVIEW_ICON + button.expand_icon = true + button.add_theme_constant_override("icon_max_width", 17) + button.custom_minimum_size = Vector2(105, 38) + button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _style_outline_button(button, Brand.PURPLE, dark) + button.pressed.connect(_open_review.bind(str(item.get("id", "")))) + return button + + +func _add_table_cell(row: HBoxContainer, control: Control, ratio: float) -> void: + control.size_flags_horizontal = Control.SIZE_EXPAND_FILL + control.size_flags_vertical = Control.SIZE_SHRINK_CENTER + control.size_flags_stretch_ratio = ratio + row.add_child(control) + + +func _launch_or_resume() -> void: + if not pending_action.is_empty(): + return + pending_action = "resume" if has_active_case(CCSService.attempt) else "start" + _render_active_section() + if pending_action == "resume": + main.show_screen("ccs_player") + pending_action = "" + else: + CCSService.create_attempt("", "timed") + + +func _open_review(attempt_id: String) -> void: + if not pending_action.is_empty(): + return + pending_action = "review" + pending_attempt_id = attempt_id + status_label.text = "Opening case review..." + CCSService.load_attempt(attempt_id) + + +func _confirm_abandon() -> void: + if not has_active_case(CCSService.attempt): + return + pending_action = "abandon" + CCSService.abandon() + _render_active_section() + + +func _on_attempt_changed() -> void: + if not visible: + return + if pending_action in ["start", "resume_after_conflict"] and has_active_case(CCSService.attempt): + pending_action = "" + main.show_screen("ccs_player") + return + if pending_action == "review" and str(CCSService.attempt.get("id", "")) == pending_attempt_id: + pending_action = "" + pending_attempt_id = "" + main.show_screen("ccs_review", CCSService.attempt) + return + pending_action = "" + _render_active_section() + + +func _on_attempt_abandoned() -> void: + if not visible: + return + pending_action = "" + CCSService.load_active() + CCSService.load_prediction() + CCSService.load_rating() + _load_history_page() + + +func _on_filter_selected(index: int) -> void: + history_status = str(filter_option.get_item_metadata(index)) + _load_history_page() + + +func _on_sort_selected(index: int) -> void: + history_sort = str(sort_option.get_item_metadata(index)) + _load_history_page() + + +func _load_history_page() -> void: + status_label.text = "Loading case history..." + load_more_button.disabled = true + CCSService.load_history("", history_status, history_sort) + + +func _load_more() -> void: + if CCSService.history_next_cursor.is_empty(): + return + load_more_button.disabled = true + CCSService.load_history(CCSService.history_next_cursor, history_status, history_sort) -func _attempt_ready() -> void: - if visible and not CCSService.attempt.is_empty(): main.show_screen("ccs_player") func _on_request_failed(message: String) -> void: - status.text = message - start_button.disabled = CCSService.cases.is_empty() + if not visible: + return + if pending_action == "start" and message.contains("409"): + pending_action = "resume_after_conflict" + status_label.text = "An active case was found. Resuming it..." + CCSService.load_active() + return + pending_action = "" + pending_attempt_id = "" + status_label.text = message + load_more_button.disabled = false + _render_active_section() + + +func _update_responsive_layout() -> void: + var use_compact := size.x < 940 + if compact_layout == use_compact: + return + compact_layout = use_compact + history_header.vertical = compact_layout + filter_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END + sort_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END + _render_active_section() + _render_history() + + +func _availability_message() -> String: + if CCSService.cases.is_empty(): + return "No published CCS cases are currently available." + return "%d published cases are available. A random case will begin immediately." % CCSService.cases.size() + + +func _score_text(item: Dictionary) -> String: + var score: Variant = item.get("score", null) + return "--" if score == null else "%d%%" % int(round(float(score))) + + +func _rating_text(item: Dictionary) -> String: + var rating_value: Variant = item.get("rating", null) + if typeof(rating_value) != TYPE_DICTIONARY: + return "--" + var rating: Dictionary = rating_value + if not bool(rating.get("rated", false)): + return "--" + return "%d (%+d)" % [int(rating.get("userElo", 1000)), int(rating.get("userEloDelta", 0))] + + +func _format_local_datetime(value: String) -> String: + if value.is_empty(): + return "--" + var unix := Time.get_unix_time_from_datetime_string(value) + var timezone: Dictionary = Time.get_time_zone_from_system() + var local := Time.get_datetime_dict_from_unix_time(int(unix) + int(timezone.get("bias", 0)) * 60) + return format_datetime_parts(local) + + +static func format_datetime_parts(parts: Dictionary) -> String: + const MONTHS := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + var month_index: int = clamp(int(parts.get("month", 1)) - 1, 0, 11) + var hour_24 := int(parts.get("hour", 0)) + var suffix := "AM" if hour_24 < 12 else "PM" + var hour_12 := hour_24 % 12 + if hour_12 == 0: + hour_12 = 12 + return "%s %d, %d\n%d:%02d %s" % [MONTHS[month_index], int(parts.get("day", 1)), int(parts.get("year", 1970)), hour_12, int(parts.get("minute", 0)), suffix] + + +static func has_active_case(attempt: Dictionary) -> bool: + return not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"] + + +static func remaining_time_text(attempt: Dictionary, now_unix: float = -1.0) -> String: + var deadline := str(attempt.get("finalOrdersDeadlineUtc", "")) if str(attempt.get("state", "")) == "final_orders" else str(attempt.get("deadlineUtc", "")) + if deadline.is_empty() or deadline == "": + return "Timed" + var current_unix := Time.get_unix_time_from_system() if now_unix < 0.0 else now_unix + var seconds: int = max(0, int(Time.get_unix_time_from_datetime_string(deadline) - current_unix)) + var minutes := int(ceil(float(seconds) / 60.0)) + return "%d min remaining" % minutes + + +static func humanize(value: String) -> String: + return value.replace("_", " ").capitalize() + + +func _style_muted_label(label: Label, dark: bool, font_size: int) -> void: + Brand.apply_font(label) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", Brand.muted_color(dark)) + + +func _style_option(option: OptionButton, dark: bool) -> void: + Brand.apply_font(option) + option.add_theme_font_size_override("font_size", 14) + option.add_theme_color_override("font_color", Brand.text_color(dark)) + for state in ["normal", "hover", "pressed"]: + option.add_theme_stylebox_override(state, Brand.secondary_button_box(dark, state != "normal")) + + +func _style_gradient_button(button: Button) -> void: + Brand.apply_font(button) + button.add_theme_font_size_override("font_size", 15) + for state in ["normal", "hover", "pressed"]: + button.add_theme_stylebox_override(state, Brand.gradient_button_box()) + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + + +func _style_outline_button(button: Button, accent: Color, dark: bool) -> void: + Brand.apply_font(button) + button.add_theme_font_size_override("font_size", 14) + var normal := Style.button_box(Color(0, 0, 0, 0), accent) + var hover := Style.button_box(Color(accent, 0.12), accent) + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", hover) + button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.08)) + button.add_theme_color_override("font_hover_color", accent) + button.add_theme_color_override("icon_normal_color", accent) + + +func _section_box(dark: bool) -> StyleBoxFlat: + var box := Brand.content_card_box(dark, false) + box.content_margin_left = 16 + box.content_margin_right = 16 + box.content_margin_top = 14 + box.content_margin_bottom = 14 + return box + + +func _active_card_box(dark: bool) -> StyleBoxFlat: + var box := Brand.content_card_box(dark, true, Brand.TEAL) + box.bg_color = Color("0c1c31") if dark else Color("f4fbfb") + box.border_color = Color("174c58") if dark else Color("b9e7e2") + box.content_margin_left = 16 + box.content_margin_right = 16 + box.content_margin_top = 16 + box.content_margin_bottom = 16 + return box + + +func _notice_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("101a32") if dark else Color("f5f7ff") + box.border_color = Color("29365d") if dark else Color("dbe3f4") + box.set_border_width_all(1) + box.set_corner_radius_all(7) + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 9 + box.content_margin_bottom = 9 + return box + + +func _row_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("0c162b") if dark else Color("ffffff") + box.border_color = Color("273450") if dark else Color("e3e8f1") + box.border_width_top = 1 + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +func _clear(container: Node) -> void: + for child in container.get_children(): + container.remove_child(child) + child.queue_free() diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index 12e724f..b272363 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -532,7 +532,7 @@ func _on_abandoned() -> void: if not leaving: return leaving = false - main.show_screen("selection", "step3") + main.show_screen("ccs_hub") func _request_failed(message: String) -> void: diff --git a/scripts/ui/ccs_review.gd b/scripts/ui/ccs_review.gd index 9ca5067..ce78164 100644 --- a/scripts/ui/ccs_review.gd +++ b/scripts/ui/ccs_review.gd @@ -36,7 +36,7 @@ func _ready() -> void: report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog) var row := HBoxContainer.new(); add_child(row) var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases) - var back := Button.new(); back.text = "Back to Step 3 Modes"; back.pressed.connect(func(): main.show_screen("selection", "step3")); row.add_child(back) + var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(back) status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status) CCSService.request_failed.connect(_request_failed) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) diff --git a/tests/ccs_hub_test.gd b/tests/ccs_hub_test.gd new file mode 100644 index 0000000..4bd2fba --- /dev/null +++ b/tests/ccs_hub_test.gd @@ -0,0 +1,26 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var hub = load("res://scripts/ui/ccs_hub.gd") + _assert(hub.has_active_case({"state": "active"}), "active CCS attempts should be resumable") + _assert(hub.has_active_case({"state": "final_orders"}), "final-orders CCS attempts should be resumable") + _assert(not hub.has_active_case({"state": "completed"}), "completed CCS attempts should not appear active") + _assert(not hub.has_active_case({"state": "abandoned"}), "abandoned CCS attempts should not appear active") + _assert(hub.remaining_time_text({"state": "active", "deadlineUtc": "2026-07-19T12:15:00Z"}, 1784462400.0) == "15 min remaining", "active case countdown should use the case deadline") + _assert(hub.humanize("emergency_department") == "Emergency Department", "CCS locations should be human readable") + _assert(hub.format_datetime_parts({"year": 2026, "month": 7, "day": 19, "hour": 13, "minute": 5}) == "Jul 19, 2026\n1:05 PM", "history dates should use the compact local format") + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/ccs_hub_test.gd.uid b/tests/ccs_hub_test.gd.uid new file mode 100644 index 0000000..fbf6088 --- /dev/null +++ b/tests/ccs_hub_test.gd.uid @@ -0,0 +1 @@ +uid://fi27rqa8vcoj diff --git a/tests/ccs_hub_ui_test.gd b/tests/ccs_hub_ui_test.gd new file mode 100644 index 0000000..dfca5c6 --- /dev/null +++ b/tests/ccs_hub_ui_test.gd @@ -0,0 +1,57 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var service = root.get_node("CCSService") + var theme_service = root.get_node("UIThemeService") + var prior_dark_mode: bool = theme_service.is_dark_mode() + service.cases = [{"externalId": "case-1"}] + service.attempt = {"id": "active", "state": "active", "createdUtc": "2026-07-19T12:00:00Z", "deadlineUtc": "2026-07-19T12:20:00Z", "location": "emergency_department"} + service.rating = {"elo": 1040, "ratedCaseCount": 3} + service.prediction = {"ccsScore": 72.0, "contributingCases": 3} + service.history = [{"id": "completed", "caseTitle": "Completed case", "state": "completed", "createdUtc": "2026-07-18T12:00:00Z", "score": 80.0}] + service.history_next_cursor = "next-page" + var host := Control.new() + host.size = Vector2(1400, 800) + root.add_child(host) + var screen = load("res://scripts/ui/ccs_hub.gd").new() + screen.size = host.size + host.add_child(screen) + await process_frame + screen._update_responsive_layout() + screen._render_active_section() + screen._render_history() + await process_frame + _assert(screen.history_list.get_child_count() == 1, "CCS history should render terminal attempts") + _assert(screen.load_more_button.visible, "CCS history should expose cursor pagination") + var active_layout: BoxContainer = screen.find_child("ActiveCardLayout", true, false) + _assert(active_layout != null and not active_layout.vertical, "wide CCS screens should use the horizontal active card") + var primary: Button = screen.find_child("PrimaryCcsAction", true, false) + _assert(primary != null and primary.text == "Resume Case", "an unfinished case should expose Resume Case") + theme_service.set_dark_mode(false) + await process_frame + var light_box := screen.active_section.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_box.bg_color.is_equal_approx(screen._section_box(false).bg_color), "CCS should apply its light surface after a theme change") + screen.size = Vector2(760, 800) + screen._update_responsive_layout() + await process_frame + active_layout = screen.find_child("ActiveCardLayout", true, false) + _assert(active_layout != null and active_layout.vertical, "narrow CCS screens should stack the active card") + _assert(not screen.table_header.visible, "narrow CCS screens should render history cards without a table header") + host.queue_free() + await process_frame + theme_service.set_dark_mode(prior_dark_mode) + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/ccs_hub_ui_test.gd.uid b/tests/ccs_hub_ui_test.gd.uid new file mode 100644 index 0000000..b2bc76c --- /dev/null +++ b/tests/ccs_hub_ui_test.gd.uid @@ -0,0 +1 @@ +uid://c64rhs53ln007