diff --git a/align_app/app/runs_presentation.py b/align_app/app/runs_presentation.py index cfb0387..30bb1c8 100644 --- a/align_app/app/runs_presentation.py +++ b/align_app/app/runs_presentation.py @@ -74,6 +74,39 @@ def experiment_item_to_table_row( } +def run_to_table_row_direct(run: Run, probe: Probe) -> Dict[str, Any]: + """Convert Run directly to table row without building full state dict.""" + kdma_values = run.decider_params.alignment_target.kdma_values + alignment_summary = ( + ", ".join(f"{readable(kv.kdma)} {kv.value}" for kv in kdma_values) + if kdma_values + else "None" + ) + + display_state = probe.display_state or "" + choices = probe.choices or [] + choice_texts = " ".join(c.get("unstructured", "") for c in choices) + + decision_text = "" + if run.decision: + choice_letter = chr(run.decision.choice_index + ord("A")) + decision_text = ( + f"{choice_letter}. {run.decision.adm_result.decision.unstructured}" + ) + + return { + "id": run.compute_cache_key(), + "scenario_id": probe.scenario_id, + "scene_id": probe.scene_id, + "probe_text": display_state, + "decider_name": run.decider_name, + "llm_backbone_name": run.llm_backbone_name, + "alignment_summary": alignment_summary, + "decision_text": decision_text, + "searchable_text": f"{display_state} {choice_texts}", + } + + def get_max_alignment_attributes(decider_configs: Dict) -> int: if not decider_configs: return 0 diff --git a/align_app/app/runs_state_adapter.py b/align_app/app/runs_state_adapter.py index 86a2ab1..1fc6f68 100644 --- a/align_app/app/runs_state_adapter.py +++ b/align_app/app/runs_state_adapter.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Callable +from typing import Optional, Callable from trame.app import asynchronous from trame.app.file_upload import ClientFile from trame.decorators import TrameApp, controller, change, trigger @@ -31,18 +31,25 @@ def __init__( self.decider_registry = decider_registry self._add_system_adm_callback = add_system_adm_callback self.server.state.pending_cache_keys = [] + self.server.state.table_collapsed = False + self.server.state.comparison_collapsed = False self.server.state.runs_table_modal_open = False self.server.state.runs_table_selected = [] self.server.state.runs_table_search = "" self.server.state.runs_table_headers = [ - {"title": "", "key": "in_comparison", "sortable": False, "width": "24px"}, - {"title": "Scenario", "key": "scenario_id"}, - {"title": "Scene", "key": "scene_id"}, - {"title": "Situation", "key": "probe_text", "sortable": False}, - {"title": "Decider", "key": "decider_name"}, - {"title": "LLM", "key": "llm_backbone_name"}, - {"title": "Alignment", "key": "alignment_summary"}, - {"title": "Decision", "key": "decision_text"}, + {"title": "", "key": "in_comparison", "sortable": False, "width": "40px"}, + {"title": "Scenario", "key": "scenario_id", "width": "150px"}, + {"title": "Scene", "key": "scene_id", "width": "120px"}, + { + "title": "Situation", + "key": "probe_text", + "sortable": False, + "width": "200px", + }, + {"title": "Decider", "key": "decider_name", "width": "180px"}, + {"title": "LLM", "key": "llm_backbone_name", "width": "180px"}, + {"title": "Alignment", "key": "alignment_summary", "width": "150px"}, + {"title": "Decision", "key": "decision_text", "width": "180px"}, ] self.server.state.import_experiment_file = None self.server.state.adm_browser_open = False @@ -51,32 +58,59 @@ def __init__( self.server.state.selected_system_adms = [] self.server.state.probe_dirty = {} self.server.state.config_dirty = {} + self.server.state.runs = {} + self.server.state.runs_to_compare = [] self.table_filter = RunsTableFilter(server) - self._sync_from_runs_data(runs_registry.get_all_runs()) + self._update_table_rows() @property def state(self): return self.server.state - def _sync_from_runs_data(self, runs_dict: Dict[str, Run]): - new_runs = {} - for run_id, run in runs_dict.items(): - new_run = runs_presentation.run_to_state_dict( + def _add_run_to_comparison(self, run: Run): + """Add single run to state.runs.""" + if run.id not in self.state.runs: + run_dict = runs_presentation.run_to_state_dict( + run, self.probe_registry, self.decider_registry + ) + self.state.runs = {**self.state.runs, run.id: run_dict} + + def _remove_run_from_comparison(self, run_id: str): + """Remove run from state.runs.""" + if run_id in self.state.runs: + self.state.runs = {k: v for k, v in self.state.runs.items() if k != run_id} + + def _update_run_in_comparison(self, run: Run): + """Update single run in state.runs if it's in comparison.""" + if run.id in self.state.runs_to_compare: + run_dict = runs_presentation.run_to_state_dict( run, self.probe_registry, self.decider_registry ) - new_runs[run_id] = new_run + self.state.runs = {**self.state.runs, run.id: run_dict} + + def _rebuild_comparison_runs(self): + """Rebuild state.runs from runs_to_compare (for imports/registry changes).""" + new_runs = {} + for run_id in self.state.runs_to_compare: + run = self.runs_registry.get_run(run_id) + if run: + new_runs[run_id] = runs_presentation.run_to_state_dict( + run, self.probe_registry, self.decider_registry + ) self.state.runs = new_runs - run_table_rows_by_id = { - row["id"]: row - for row in ( - runs_presentation.run_to_table_row(run_dict) - for run_dict in new_runs.values() + def _update_table_rows(self): + """Update table rows without touching state.runs.""" + all_runs = self.runs_registry.get_all_runs() + run_table_rows = [ + runs_presentation.run_to_table_row_direct( + run, self.probe_registry.get_probe(run.probe_id) ) - } - run_table_rows = list(run_table_rows_by_id.values()) + for run in all_runs.values() + ] + run_table_rows_by_id = {row["id"]: row for row in run_table_rows} - active_cache_keys = {run.compute_cache_key() for run in runs_dict.values()} + active_cache_keys = {run.compute_cache_key() for run in all_runs.values()} stored_items = self.runs_registry.get_all_experiment_items() experiment_table_rows = [ runs_presentation.experiment_item_to_table_row( @@ -86,32 +120,26 @@ def _sync_from_runs_data(self, runs_dict: Dict[str, Run]): if cache_key not in active_cache_keys ] - self.table_filter.set_all_rows(run_table_rows + experiment_table_rows) + self.table_filter.set_all_rows( + list(run_table_rows_by_id.values()) + experiment_table_rows + ) probes = self.probe_registry.get_probes() self.state.base_scenarios = extract_base_scenarios(probes) - self.state.probe_dirty = {} - self.state.config_dirty = {} - - if not self.state.runs: - self.state.runs_to_compare = [] - self.state.runs_json = "[]" - self.state.run_edit_configs = {} - @controller.set("reset_runs_state") def reset_state(self): self.runs_registry.clear_runs() - self._sync_from_runs_data({}) + self.state.runs = {} + self.state.runs_to_compare = [] + self._update_table_rows() self.create_default_run() @controller.set("clear_all_runs") def clear_all_runs(self): - self.runs_registry.clear_all() - self._sync_from_runs_data({}) + self.state.runs = {} self.state.runs_to_compare = [] - self.state.runs_table_selected = [] - self.create_default_run() + self._update_table_rows() def create_default_run(self): probes = self.probe_registry.get_probes() @@ -176,6 +204,37 @@ def delete_run_from_compare(self, column_index): runs_to_compare.pop(column_index) self.state.runs_to_compare = runs_to_compare + @controller.set("toggle_table_collapsed") + def toggle_table_collapsed(self): + self.state.table_collapsed = not self.state.table_collapsed + + @controller.set("toggle_comparison_collapsed") + def toggle_comparison_collapsed(self): + self.state.comparison_collapsed = not self.state.comparison_collapsed + + @controller.set("toggle_run_in_comparison") + def toggle_run_in_comparison(self, cache_key): + run = self.runs_registry.get_run_by_cache_key(cache_key) + + if run and run.id in self.state.runs_to_compare: + self.state.runs_to_compare = [ + rid for rid in self.state.runs_to_compare if rid != run.id + ] + return + + if not run: + run = self.runs_registry.materialize_experiment_item(cache_key) + if not run: + return + + self.state.runs_to_compare = [run.id, *self.state.runs_to_compare] + + if run.id not in self.state.runs: + run_dict = runs_presentation.run_to_state_dict( + run, self.probe_registry, self.decider_registry + ) + self.state.runs = {**self.state.runs, run.id: run_dict} + @controller.set("copy_run") def copy_run(self, run_id, column_index): source_run = self.runs_registry.get_run(run_id) @@ -205,18 +264,17 @@ def _sync_run_to_state(self, run: Run, insert_at_index=None): else: runs_to_compare.insert(insert_at_index, run.id) self.state.runs_to_compare = runs_to_compare + self._update_table_rows() def _handle_run_update(self, old_run_id: str, new_run: Optional[Run]): if new_run: - # Always replace old run ID with new ID in the comparison view - # This keeps the run in the same UI position - # Note: Registry layer handles whether to keep or remove the old run - # based on decision state (see _create_update_method) self.state.runs_to_compare = [ new_run.id if rid == old_run_id else rid for rid in self.state.runs_to_compare ] - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._remove_run_from_comparison(old_run_id) + self._add_run_to_comparison(new_run) + self._update_table_rows() @controller.set("update_run_scene") def update_run_scene(self, run_id: str, scene_id: str): @@ -376,14 +434,15 @@ def save_probe_edits( ) self.runs_registry.add_run(new_run) - # Cleanup: If original run had no decision (was a draft), remove it if run.decision is None: self.runs_registry.remove_run(run_id) self.state.runs_to_compare = [ new_run_id if rid == run_id else rid for rid in self.state.runs_to_compare ] - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._remove_run_from_comparison(run_id) + self._add_run_to_comparison(new_run) + self._update_table_rows() @controller.set("save_config_edits") def save_config_edits(self, run_id: str, current_yaml: str = ""): @@ -474,7 +533,10 @@ def _create_run_with_edited_config( llm_backbone=run.llm_backbone_name, ) - if root_config == new_config: + def normalize_config(cfg): + return yaml.safe_load(yaml.dump(cfg, sort_keys=True)) + + if normalize_config(root_config) == normalize_config(new_config): new_decider_name = root_decider_name else: new_decider_name = self.decider_registry.add_edited_decider( @@ -498,14 +560,15 @@ def _create_run_with_edited_config( ) self.runs_registry.add_run(new_run) - # Cleanup: If original run had no decision (was a draft), remove it if run.decision is None: self.runs_registry.remove_run(run_id) self.state.runs_to_compare = [ new_run_id if rid == run_id else rid for rid in self.state.runs_to_compare ] - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._remove_run_from_comparison(run_id) + self._add_run_to_comparison(new_run) + self._update_table_rows() return new_run_id def _add_pending_cache_key(self, cache_key: str): @@ -553,8 +616,10 @@ async def _execute_run_decision(self, run_id: str): await self.runs_registry.execute_run_decision(run_id) with self.state: - all_runs = self.runs_registry.get_all_runs() - self._sync_from_runs_data(all_runs) + run = self.runs_registry.get_run(run_id) + if run: + self._update_run_in_comparison(run) + self._update_table_rows() self._remove_pending_cache_key(cache_key) @controller.set("execute_run_decision") @@ -572,7 +637,15 @@ def trigger_export_runs_zip(self) -> bytes: def trigger_export_table_runs_zip(self) -> bytes: selected = self.state.runs_table_selected if not selected: - return export_runs_to_zip(self.state.runs) + all_runs = self.runs_registry.get_all_runs() + runs_to_export = { + rid: runs_presentation.run_to_state_dict( + r, self.probe_registry, self.decider_registry + ) + for rid, r in all_runs.items() + if r.decision + } + return export_runs_to_zip(runs_to_export) selected_runs = {} for item in selected: @@ -608,6 +681,7 @@ def add_selected_runs_to_compare(self): return new_runs_to_compare = [] + runs_to_add = [] for item in selected: cache_key = item["id"] if isinstance(item, dict) else item @@ -619,11 +693,16 @@ def add_selected_runs_to_compare(self): if run and run.id not in new_runs_to_compare: new_runs_to_compare.append(run.id) + runs_to_add.append(run) + + self.state.runs = {} + for run in runs_to_add: + self._add_run_to_comparison(run) self.state.runs_to_compare = new_runs_to_compare self.state.runs_table_modal_open = False self.state.runs_table_selected = [] - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._update_table_rows() @controller.set("on_table_row_click") def on_table_row_click(self, _event, item): @@ -637,8 +716,9 @@ def on_table_row_click(self, _event, item): run = self.runs_registry.materialize_experiment_item(cache_key) if run and run.id not in self.state.runs_to_compare: - self.state.runs_to_compare = [*self.state.runs_to_compare, run.id] - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self.state.runs_to_compare = [run.id, *self.state.runs_to_compare] + self._add_run_to_comparison(run) + self._update_table_rows() @change("runs") def update_runs_json(self, **_): @@ -661,7 +741,7 @@ def on_import_experiment_file(self, import_experiment_file, **_): self.decider_registry.add_deciders(result.deciders) self.runs_registry.add_experiment_items(result.items) - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._update_table_rows() self.state.import_experiment_file = None @@ -680,7 +760,7 @@ def trigger_import_directory_files(self, files_data): self.probe_registry.add_probes(result.probes) self.decider_registry.add_deciders(result.deciders) self.runs_registry.add_experiment_items(result.items) - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._update_table_rows() @trigger("import_zip_bytes") def trigger_import_zip_bytes(self, zip_content): @@ -688,11 +768,12 @@ def trigger_import_zip_bytes(self, zip_content): self.probe_registry.add_probes(result.probes) self.decider_registry.add_deciders(result.deciders) self.runs_registry.add_experiment_items(result.items) - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._update_table_rows() def update_decider_registry(self, new_registry): self.decider_registry = new_registry - self._sync_from_runs_data(self.runs_registry.get_all_runs()) + self._rebuild_comparison_runs() + self._update_table_rows() @controller.set("open_adm_browser") def open_adm_browser(self, run_id: str | None = None): diff --git a/align_app/app/ui.py b/align_app/app/ui.py index 93d51c2..cec1010 100644 --- a/align_app/app/ui.py +++ b/align_app/app/ui.py @@ -886,12 +886,211 @@ def cell_with_tooltip(key: str): html.Span(f"{{{{ item.{key} }}}}", v_bind_title=f"item.{key}") +def situation_cell_with_info_icon(): + """Create situation cell with truncated text and info icon tooltip.""" + with html.Template(raw_attrs=['v-slot:item.probe_text="{ item }"']): + with html.Div(classes="d-flex align-center", style="gap: 4px;"): + html.Span( + "{{ item.probe_text }}", + style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;", + ) + with vuetify3.VTooltip(location="top", max_width="400px"): + with vuetify3.Template(v_slot_activator="{ props }"): + vuetify3.VIcon( + "mdi-information-outline", + size="x-small", + v_bind="props", + classes="text-grey flex-shrink-0", + style="cursor: help;", + ) + html.Div( + "{{ item.probe_text }}", + style="white-space: normal; word-wrap: break-word;", + ) + + def filterable_column(key: str, title: str, filter_var: str, options_var: str): """Create sortable column header with filter and cell tooltip.""" sortable_filter_header(key, title, filter_var, options_var) cell_with_tooltip(key) +class RunsTablePanel(html.Div): + def __init__(self, **kwargs): + super().__init__( + classes="runs-table-panel", + style=( + "table_collapsed ? " + "'width: 0; min-width: 0; height: 100%; overflow: hidden; " + "transition: all 0.3s ease;' : " + "'flex: 1; min-width: 25vw; height: 100%; " + "transition: all 0.3s ease;'", + ), + **kwargs, + ) + ctrl = self.server.controller + with self: + with vuetify3.VCard( + v_if=("!table_collapsed",), + elevation=2, + classes="d-flex flex-column ma-1", + style="height: calc(100% - 8px); overflow: hidden;", + ): + with html.Div( + classes="d-flex align-start flex-wrap flex-shrink-0 ga-1", + style="padding: 0 4px 0 0;", + ): + with vuetify3.VBtn( + variant="flat", + color="grey-lighten-3", + click=(ctrl.toggle_table_collapsed,), + classes="text-none", + style="border-radius: 0 0 8px 0;", + size="small", + ): + vuetify3.VIcon("mdi-chevron-left", size="small", classes="mr-1") + html.Span("Runs", classes="text-caption") + vuetify3.VIcon("mdi-table", size="small", classes="ml-1") + vuetify3.VTextField( + v_model=("runs_table_search",), + placeholder="Search...", + prepend_inner_icon="mdi-magnify", + clearable=True, + hide_details=True, + density="compact", + variant="underlined", + style="min-width: 100px; max-width: 200px; flex: 1 1 100px;", + ) + with vuetify3.VBtn( + size="x-small", + variant="text", + click=(ctrl.clear_all_table_filters,), + prepend_icon="mdi-filter-off", + ): + html.Span("Clear") + + with html.Div(style="flex: 1; overflow: hidden; display: flex;"): + with vuetify3.VDataTable( + items=("runs_table_items",), + headers=("runs_table_headers",), + item_value="id", + hover=True, + density="compact", + search=("runs_table_search",), + items_per_page=(50,), + click_row=(ctrl.on_table_row_click, "[$event, item]"), + fixed_header=True, + style="flex: 1; overflow: hidden;", + ): + with html.Template( + raw_attrs=['v-slot:item.in_comparison="{ item }"'] + ): + with html.Div(style="text-overflow: clip;"): + vuetify3.VIcon( + raw_attrs=[ + "@click.stop", + ':icon="runs_to_compare.some(' + "rid => runs[rid]?.cache_key === item.id) " + "? 'mdi-eye' : 'mdi-eye-off'\"", + ], + size="small", + style="cursor: pointer;", + click=(ctrl.toggle_run_in_comparison, "[item.id]"), + ) + filterable_column( + "scenario_id", + "Scenario", + "runs_table_filter_scenario", + "runs_table_scenario_options", + ) + filterable_column( + "scene_id", + "Scene", + "runs_table_filter_scene", + "runs_table_scene_options", + ) + situation_cell_with_info_icon() + filterable_column( + "decider_name", + "Decider", + "runs_table_filter_decider", + "runs_table_decider_options", + ) + filterable_column( + "llm_backbone_name", + "LLM", + "runs_table_filter_llm", + "runs_table_llm_options", + ) + filterable_column( + "alignment_summary", + "Alignment", + "runs_table_filter_alignment", + "runs_table_alignment_options", + ) + filterable_column( + "decision_text", + "Decision", + "runs_table_filter_decision", + "runs_table_decision_options", + ) + with html.Template(raw_attrs=["v-slot:no-data"]): + with html.Div( + classes="d-flex flex-column align-center pa-4" + ): + html.Div("No runs found", classes="text-body-2") + + +class ComparisonPanel(html.Div): + def __init__(self, **kwargs): + super().__init__( + classes="comparison-panel d-flex", + style=( + "comparison_collapsed ? " + "'width: 0; min-width: 0; height: 100%; overflow: hidden; margin-left: auto; " + "transition: all 0.3s ease;' : " + "'flex: 1; min-width: 200px; height: 100%; margin-left: auto; " + "transition: all 0.3s ease;'", + ), + **kwargs, + ) + ctrl = self.server.controller + with self: + with vuetify3.VCard( + v_if=("!comparison_collapsed",), + elevation=2, + classes=( + "isDragging ? 'd-flex flex-column ma-1 drop-zone-active' : 'd-flex flex-column ma-1'", + ), + style="flex: 1; height: calc(100% - 8px); overflow: hidden;", + raw_attrs=[ + '@dragover.prevent="isDragging = true"', + '@dragleave.prevent="isDragging = false"', + f'@drop.prevent="{DROP_HANDLER_JS}"', + ], + ): + with html.Div( + classes="d-flex justify-end align-start flex-shrink-0", + style="padding: 0 0 0 4px;", + ): + with vuetify3.VBtn( + variant="flat", + color="grey-lighten-3", + click=(ctrl.toggle_comparison_collapsed,), + classes="text-none", + style="border-radius: 0 0 0 8px;", + size="small", + ): + vuetify3.VIcon("mdi-compare", size="small", classes="mr-1") + html.Span("Compare", classes="text-caption") + vuetify3.VIcon( + "mdi-chevron-right", size="small", classes="ml-1" + ) + + with html.Div(style="flex: 1; overflow: auto;"): + ResultsComparison() + + class RunsTableModal(html.Div): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -1048,13 +1247,19 @@ def __init__(self, **kwargs): with html.Template( raw_attrs=['v-slot:item.in_comparison="{ item }"'] ): - with html.Span(style="font-size: 12px;"): + with html.Div(style="text-overflow: clip;"): vuetify3.VIcon( - "mdi-eye", - size="12", - v_if=( - "runs_to_compare.some(" - "rid => runs[rid]?.cache_key === item.id)" + raw_attrs=[ + "@click.stop", + ':icon="runs_to_compare.some(' + "rid => runs[rid]?.cache_key === item.id) " + "? 'mdi-eye' : 'mdi-eye-off'\"", + ], + size="small", + style="cursor: pointer;", + click=( + self.server.controller.toggle_run_in_comparison, + "[item.id]", ), ) filterable_column( @@ -1069,7 +1274,7 @@ def __init__(self, **kwargs): "runs_table_filter_scene", "runs_table_scene_options", ) - cell_with_tooltip("probe_text") + situation_cell_with_info_icon() filterable_column( "decider_name", "Decider", @@ -1172,9 +1377,11 @@ def __init__(self, **kwargs): class ResultsComparison(html.Div): def __init__(self, **kwargs): - super().__init__(classes="d-inline-flex flex-wrap ga-4 pa-1", **kwargs) + super().__init__(classes="d-inline-flex flex-wrap ga-4", **kwargs) with self: - with vuetify3.VExpansionPanels(multiple=True, variant="accordion"): + with vuetify3.VExpansionPanels( + multiple=True, variant="accordion", classes="ma-1" + ): PanelSection(child=RunNumber) PanelSection(child=Probe) PanelSection(child=Decider) @@ -1262,12 +1469,6 @@ def __init__( if reload: with vuetify3.VBtn(icon=True, click=reload): vuetify3.VIcon("mdi-refresh") - with vuetify3.VBtn( - click=self.server.controller.open_runs_table_modal, - disabled=("Object.keys(runs).length === 0",), - prepend_icon="mdi-table", - ): - html.Span("Browse Runs") vuetify3.VFileInput( v_model=("import_experiment_file", None), accept=".zip", @@ -1347,29 +1548,56 @@ def __init__( ".v-textarea .v-field__input { overflow-y: hidden !important; }" ".v-expansion-panel { max-width: none !important; }" ".config-textarea textarea { white-space: pre; overflow-x: auto; }" - ".v-data-table table { table-layout: fixed; width: 100%; }" - ".v-data-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }" - ".v-data-table th { vertical-align: top; }" - ".v-data-table th:first-child { padding-top: 8px; }" + ".runs-table-panel .v-data-table { display: flex; flex-direction: column; }" + ".runs-table-panel .v-data-table > .v-table__wrapper { flex: 1; overflow-y: auto; }" + ".runs-table-panel .v-data-table table { table-layout: fixed; width: 100%; }" + ".runs-table-panel .v-data-table td " + "{ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }" + ".runs-table-panel .v-data-table th { vertical-align: top; }" + ".runs-table-panel .v-data-table th:first-child { padding-top: 8px; }" ".drop-zone-active { outline: 3px dashed #1976d2 !important; outline-offset: -3px; }" "'" ) ) - with vuetify3.VContainer( - fluid=True, - classes=( - "isDragging ? 'overflow-auto pa-0 drop-zone-active' : 'overflow-auto pa-0'", - ), - style="height: calc(100vh - 64px);", - raw_attrs=[ - '@dragover.prevent="isDragging = true"', - '@dragleave.prevent="isDragging = false"', - f'@drop.prevent="{DROP_HANDLER_JS}"', - ], + with html.Div( + classes="d-flex", + style="height: calc(100vh - 64px); position: relative;", ): with html.Div( - style="min-width: 100%; width: fit-content; padding: 16px;", + v_if=("table_collapsed",), + style="position: absolute; left: 0; top: 0; z-index: 1;", + ): + with vuetify3.VBtn( + variant="flat", + color="grey-lighten-3", + click=(server.controller.toggle_table_collapsed,), + classes="text-none", + style="border-radius: 0 0 8px 0;", + size="small", + ): + vuetify3.VIcon( + "mdi-chevron-right", size="small", classes="mr-1" + ) + html.Span("Runs", classes="text-caption") + vuetify3.VIcon("mdi-table", size="small", classes="ml-1") + with html.Div( + v_if=("comparison_collapsed",), + style="position: absolute; right: 0; top: 0; z-index: 1;", ): - ResultsComparison() - RunsTableModal() - AdmBrowserModal() + with vuetify3.VBtn( + variant="flat", + color="grey-lighten-3", + click=(server.controller.toggle_comparison_collapsed,), + classes="text-none", + style="border-radius: 0 0 0 8px;", + size="small", + ): + vuetify3.VIcon("mdi-compare", size="small", classes="mr-1") + html.Span("Compare", classes="text-caption") + vuetify3.VIcon( + "mdi-chevron-left", size="small", classes="ml-1" + ) + RunsTablePanel() + ComparisonPanel() + RunsTableModal() + AdmBrowserModal() diff --git a/tests/e2e/page_objects/align_page.py b/tests/e2e/page_objects/align_page.py index a8389f2..8ef4381 100644 --- a/tests/e2e/page_objects/align_page.py +++ b/tests/e2e/page_objects/align_page.py @@ -20,21 +20,38 @@ def spinner(self) -> Locator: return self.page.locator(".v-progress-circular") @property - def browse_runs_button(self) -> Locator: - return self.page.get_by_role("button", name="Browse Runs") + def runs_table_panel(self) -> Locator: + return self.page.locator(".runs-table-panel") @property - def runs_modal(self) -> Locator: - # The modal card has a toolbar with "Runs" title - return self.page.get_by_text("Runs", exact=True).locator("xpath=..") + def comparison_panel(self) -> Locator: + return self.page.locator(".comparison-panel") @property - def runs_table(self) -> Locator: - return self.runs_modal.locator(".v-data-table") + def table_collapse_button(self) -> Locator: + return self.runs_table_panel.get_by_role("button").first @property - def table_close_button(self) -> Locator: - return self.runs_modal.get_by_role("button", name="Close") + def comparison_collapse_button(self) -> Locator: + return self.comparison_panel.get_by_role("button").filter( + has=self.page.locator(".mdi-chevron-left, .mdi-chevron-right") + ) + + @property + def table_search_input(self) -> Locator: + return self.runs_table_panel.locator("input[type='text']").first + + @property + def table_filter_toggle(self) -> Locator: + return self.runs_table_panel.get_by_role("button").filter( + has=self.page.locator(".mdi-filter, .mdi-filter-off") + ) + + @property + def table_run_items(self) -> Locator: + return self.runs_table_panel.locator(".v-list-item").filter( + has=self.page.locator(".v-checkbox") + ) @property def decision_button(self) -> Locator: @@ -221,7 +238,11 @@ def get_situation_text(self) -> str: def set_situation_text(self, text: str) -> None: expect(self.situation_textarea).to_be_visible() + self.situation_textarea.click() + self.situation_textarea.press("Control+a") self.situation_textarea.fill(text) + self.situation_textarea.blur() + self.page.wait_for_timeout(200) def blur_situation_textarea(self) -> None: self.situation_textarea.blur() @@ -360,7 +381,11 @@ def get_config_yaml(self) -> str: def set_config_yaml(self, yaml_text: str) -> None: expect(self.config_textarea).to_be_visible() + self.config_textarea.click() + self.config_textarea.press("Control+a") self.config_textarea.fill(yaml_text) + self.config_textarea.blur() + self.page.wait_for_timeout(200) def blur_config_textarea(self) -> None: self.config_textarea.blur() @@ -371,7 +396,11 @@ def save_config_button(self) -> Locator: def click_save_config_button(self) -> None: expect(self.save_config_button).to_be_visible() + expect(self.save_config_button).to_be_enabled() + self.save_config_button.scroll_into_view_if_needed() + self.page.wait_for_timeout(500) self.save_config_button.click() + self.page.wait_for_timeout(1000) def get_decider_dropdown_value(self) -> str: dropdown = ( @@ -397,22 +426,47 @@ def get_run_columns(self) -> Locator: def expect_run_count(self, count: int) -> None: expect(self.get_run_columns()).to_have_count(count) - def open_browse_runs_modal(self) -> None: - expect(self.browse_runs_button).to_be_visible() - self.browse_runs_button.click() - expect(self.runs_modal).to_be_visible() - - def close_browse_runs_modal(self) -> None: - # Use Esc or close button - self.page.keyboard.press("Escape") - expect(self.runs_modal).not_to_be_visible() - - def get_table_row_count(self) -> int: - # Count rows in the data table - # Wait for the table body to be present - self.runs_table.locator("tbody").wait_for() - # Count rows - return self.runs_table.locator("tbody tr").count() + def toggle_table_panel(self) -> None: + self.table_collapse_button.click() + + def expand_table_panel(self) -> None: + panel_style = self.runs_table_panel.get_attribute("style") or "" + if "width: 60px" in panel_style: + self.toggle_table_panel() + + def collapse_table_panel(self) -> None: + panel_style = self.runs_table_panel.get_attribute("style") or "" + if "width: 60px" not in panel_style: + self.toggle_table_panel() + + def toggle_comparison_panel(self) -> None: + self.comparison_collapse_button.click() + + def expand_comparison_panel(self) -> None: + panel_style = self.comparison_panel.get_attribute("style") or "" + if "width: 60px" in panel_style: + self.toggle_comparison_panel() + + def collapse_comparison_panel(self) -> None: + panel_style = self.comparison_panel.get_attribute("style") or "" + if "width: 60px" not in panel_style: + self.toggle_comparison_panel() + + def toggle_run_selection(self, index: int) -> None: + checkbox = self.table_run_items.nth(index).locator(".v-checkbox") + expect(checkbox).to_be_visible() + checkbox.click() + + def get_table_run_count(self) -> int: + return self.table_run_items.count() + + def search_table_runs(self, query: str) -> None: + self.expand_table_panel() + expect(self.table_search_input).to_be_visible() + self.table_search_input.fill(query) + + def clear_table_search(self) -> None: + self.table_search_input.clear() def get_alignment_slider(self, index: int) -> Locator: # Find slider in alignment panel diff --git a/tests/e2e/test_load_experiments.py b/tests/e2e/test_load_experiments.py index 9a6f6c7..6288ac9 100644 --- a/tests/e2e/test_load_experiments.py +++ b/tests/e2e/test_load_experiments.py @@ -26,22 +26,17 @@ def test_load_experiments_menu_opens(page, align_app_server): def test_load_experiments_from_zip(page, align_app_server, experiments_fixtures_path): - """Test loading experiments from a zip file adds runs to the table.""" + """Test loading experiments from a zip file adds runs to the table panel.""" align_page = AlignPage(page) align_page.goto(align_app_server) - browse_runs_button = page.get_by_role("button", name="Browse Runs") - browse_runs_button.click() + table_panel = page.locator(".runs-table-panel") + expect(table_panel).to_be_visible() - modal = page.locator(".v-dialog") - expect(modal).to_be_visible() + def get_table_items_count(): + return page.evaluate("window.trame.state.state.runs_table_items?.length || 0") - initial_rows = modal.locator("table tbody tr") - expect(initial_rows.first).to_be_visible() - initial_count = initial_rows.count() - - page.keyboard.press("Escape") - expect(modal).not_to_be_visible() + initial_count = get_table_items_count() load_button = page.get_by_role("button", name="Load Experiments", exact=True) load_button.click() @@ -55,18 +50,33 @@ def test_load_experiments_from_zip(page, align_app_server, experiments_fixtures_ file_chooser = fc_info.value file_chooser.set_files(str(EXPERIMENTS_ZIP)) - page.wait_for_timeout(3000) + page.evaluate( + """ + (async () => { + const input = trame.refs.importFileInput.$el.querySelector('input[type="file"]'); + if (input && input.files && input.files.length > 0) { + const file = input.files[0]; + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + await trame.trigger('import_zip_bytes', [Array.from(uint8Array)]); + } + })(); + """ + ) - page.keyboard.press("Escape") - page.wait_for_timeout(500) + def wait_for_import(): + for _ in range(30): + count = get_table_items_count() + if count > initial_count: + return True + page.wait_for_timeout(500) + return False - page.get_by_role("button", name="Browse Runs").click() - page.wait_for_selector(".v-dialog", state="visible", timeout=10000) + wait_for_import() - final_rows = modal.locator("table tbody tr") - expect(final_rows.first).to_be_visible() - final_count = final_rows.count() + final_count = get_table_items_count() assert final_count > initial_count, ( - f"Expected more rows after import. Initial: {initial_count}, Final: {final_count}" + f"Expected more runs in table panel after import. Initial: {initial_count}, " + f"Final: {final_count}" ) diff --git a/tests/e2e/test_pipeline_random.py b/tests/e2e/test_pipeline_random.py index fd50c92..c76c808 100644 --- a/tests/e2e/test_pipeline_random.py +++ b/tests/e2e/test_pipeline_random.py @@ -22,8 +22,10 @@ def test_pipeline_random_scene_change_rerun(align_page_with_decision: AlignPage) align_page.expand_scenario_panel() align_page.scene_dropdown.click() - scene_items = page.locator(".v-list-item") - second_scene = scene_items.nth(1) + scene_listbox = page.get_by_role("listbox", name="Scene-list") + expect(scene_listbox).to_be_visible() + scene_options = scene_listbox.get_by_role("option") + second_scene = scene_options.nth(1) second_scene.click() align_page.wait_for_decision_send_button()