From 02d623f3f36a7fc75b9ecc33e8f5b37b864b0695 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:14:59 +0000 Subject: [PATCH] feat: Migrate TUI logic to Core SDK and remove Textual dependency Migrated business logic from the deprecated TUI into the core SDK: - `Job` model: Added `is_terminal`, `is_successful`, `is_failed` properties. - `SubjectsEndpoint`: Added `list_by_site` and `async_list_by_site` for filtering. - `FormDesignerClient`: Added input validation to `save_form`. Removed the TUI application: - Deleted `imednet/tui` directory. - Removed `textual` dependency from `pyproject.toml`. - Updated CLI and examples to reflect removal. Added verification tests in `tests/unit/test_tui_migration.py`. --- examples/build_form_payload.py | 22 +- imednet/cli/__init__.py | 7 +- imednet/endpoints/subjects.py | 19 ++ imednet/form_designer/client.py | 12 + imednet/models/jobs.py | 15 ++ imednet/tui/__init__.py | 0 imednet/tui/app.py | 378 --------------------------- imednet/tui/form_builder.py | 139 ---------- pyproject.toml | 1 - tests/unit/test_tui_migration.py | 75 ++++++ tests/unit/tui/test_app_structure.py | 42 --- tests/unit/tui/test_form_builder.py | 20 -- 12 files changed, 126 insertions(+), 604 deletions(-) delete mode 100644 imednet/tui/__init__.py delete mode 100644 imednet/tui/app.py delete mode 100644 imednet/tui/form_builder.py create mode 100644 tests/unit/test_tui_migration.py delete mode 100644 tests/unit/tui/test_app_structure.py delete mode 100644 tests/unit/tui/test_form_builder.py diff --git a/examples/build_form_payload.py b/examples/build_form_payload.py index 49d58cb6..4ab3b532 100644 --- a/examples/build_form_payload.py +++ b/examples/build_form_payload.py @@ -120,25 +120,9 @@ def main() -> None: args.revision, ) else: - # TUI Mode - try: - # Import TUI components only when needed to support headless mode without 'textual' - from imednet.tui.app import run_tui - - # We need an SDK instance for the TUI (even if we don't use REST API for form builder) - # We can init with dummy values if env not set, but better to try load - api_key = os.getenv("IMEDNET_API_KEY", "dummy_key") - sec_key = os.getenv("IMEDNET_SECURITY_KEY", "dummy_sec") - base_url = os.getenv("IMEDNET_BASE_URL", "https://portal.prod.imednetapi.com") - - sdk = ImednetSDK(api_key=api_key, security_key=sec_key, base_url=base_url) - run_tui(sdk) - except ImportError: - print("Error: Textual not installed. Install with 'pip install textual'.") - sys.exit(1) - except Exception as e: - print(f"Error launching TUI: {e}") - sys.exit(1) + print("TUI mode has been removed. Please use the CLI arguments to run in headless mode.") + parser.print_help() + sys.exit(1) if __name__ == "__main__": diff --git a/imednet/cli/__init__.py b/imednet/cli/__init__.py index b9e43dbe..ea022e56 100644 --- a/imednet/cli/__init__.py +++ b/imednet/cli/__init__.py @@ -59,11 +59,8 @@ def main(ctx: typer.Context) -> None: # pragma: no cover - simple passthrough @app.command() def tui(ctx: typer.Context) -> None: """Launch the interactive terminal user interface (Dashboard).""" - # Import locally to avoid importing textual when just running help or other commands - from ..tui.app import run_tui - - sdk = get_sdk() - run_tui(sdk) + typer.echo("TUI mode has been removed. Please use the CLI commands.") + raise typer.Exit(code=1) if __name__ == "__main__": # pragma: no cover - manual invocation diff --git a/imednet/endpoints/subjects.py b/imednet/endpoints/subjects.py index 7abb4e18..09975f91 100644 --- a/imednet/endpoints/subjects.py +++ b/imednet/endpoints/subjects.py @@ -1,5 +1,7 @@ """Endpoint for managing subjects in a study.""" +from typing import List + from imednet.endpoints._mixins import ListGetEndpoint from imednet.models.subjects import Subject @@ -14,3 +16,20 @@ class SubjectsEndpoint(ListGetEndpoint[Subject]): PATH = "subjects" MODEL = Subject _id_param = "subjectKey" + + def list_by_site(self, study_key: str, site_id: str | int) -> List[Subject]: + """ + List subjects filtered by a specific site ID. + + Migrated from TUI logic to core SDK to support filtering. + """ + all_subjects = self.list(study_key) + # TUI Logic: Strict string comparison to handle int/str mismatch + target_site = str(site_id) + return [s for s in all_subjects if str(s.site_id) == target_site] + + async def async_list_by_site(self, study_key: str, site_id: str | int) -> List[Subject]: + """Asynchronously list subjects filtered by a specific site ID.""" + all_subjects = await self.async_list(study_key) + target_site = str(site_id) + return [s for s in all_subjects if str(s.site_id) == target_site] diff --git a/imednet/form_designer/client.py b/imednet/form_designer/client.py index 4a01c8d1..3fe331bc 100644 --- a/imednet/form_designer/client.py +++ b/imednet/form_designer/client.py @@ -52,6 +52,18 @@ def save_form( httpx.HTTPStatusError: If the server returns a non-2xx status code. ValueError: If the server returns an error. """ + if not csrf_key or not csrf_key.strip(): + raise ValueError("CSRF Key cannot be empty.") + + if form_id <= 0: + raise ValueError(f"Invalid form_id: {form_id}. Must be a positive integer.") + + if community_id <= 0: + raise ValueError(f"Invalid community_id: {community_id}. Must be a positive integer.") + + if revision < 0: + raise ValueError(f"Invalid revision: {revision}. Must be non-negative.") + url = f"{self.base_url}/app/formdez/formdez_save.php" # Critical Headers diff --git a/imednet/models/jobs.py b/imednet/models/jobs.py index c11b5c0c..f13986de 100644 --- a/imednet/models/jobs.py +++ b/imednet/models/jobs.py @@ -18,6 +18,21 @@ class Job(JsonModel): date_started: datetime = Field(default_factory=datetime.now, alias="dateStarted") date_finished: datetime = Field(default_factory=datetime.now, alias="dateFinished") + @property + def is_terminal(self) -> bool: + """Checks if the job has reached a final state (Success/Failed/Cancelled).""" + return self.state.upper() in {"COMPLETED", "SUCCESS", "FAILED", "CANCELLED"} + + @property + def is_successful(self) -> bool: + """Checks if the job completed successfully.""" + return self.state.upper() in {"COMPLETED", "SUCCESS"} + + @property + def is_failed(self) -> bool: + """Checks if the job failed or was cancelled.""" + return self.state.upper() in {"FAILED", "CANCELLED"} + class JobStatus(Job): """Extended job information returned when polling.""" diff --git a/imednet/tui/__init__.py b/imednet/tui/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/imednet/tui/app.py b/imednet/tui/app.py deleted file mode 100644 index 44865eb3..00000000 --- a/imednet/tui/app.py +++ /dev/null @@ -1,378 +0,0 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from textual.app import App, ComposeResult -from textual.containers import Container -from textual.message import Message -from textual.screen import Screen -from textual.timer import Timer -from textual.widgets import ( - DataTable, - Label, - ListItem, - ListView, - Log, - Static, - TabbedContent, - TabPane, -) - -from imednet.tui.form_builder import FormBuilderPane - -if TYPE_CHECKING: - from ..sdk import ImednetSDK - -# ============================================================================= -# LOGGING HANDLER -# ============================================================================= - - -class TextualLogHandler(logging.Handler): - """A logging handler that writes to a Textual Log widget.""" - - def __init__(self, log_widget: Log): - super().__init__() - self.log_widget = log_widget - - def emit(self, record: logging.LogRecord) -> None: - msg = self.format(record) - # Use call_after_refresh to ensure thread safety when logging from background threads - self.log_widget.write_line(msg) - - -# ============================================================================= -# WIDGETS -# ============================================================================= - - -class StudyList(ListView): - """A list of studies.""" - - class Selected(Message): - """Study selected message.""" - - def __init__(self, study_key: str, study_name: str) -> None: - self.study_key = study_key - self.study_name = study_name - super().__init__() - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - self.loaded = False - - async def on_mount(self) -> None: - if not self.loaded: - self.loading = True - try: - studies = await self.sdk.studies.async_list() - items = [] - for study in studies: - # Using study_key as the ID, but displaying name and key - # study.name might be None, so fallback - name = getattr(study, "name", "Unknown Study") - key = getattr(study, "study_key", "no-key") - label = f"{name} ({key})" - items.append(ListItem(Label(label), id=f"study-{key}")) - - self.extend(items) - self.loaded = True - except Exception as e: - self.app.notify(f"Error loading studies: {e}", severity="error") - finally: - self.loading = False - - def on_list_view_selected(self, event: ListView.Selected) -> None: - if event.item and event.item.id: - # Extract study key from ID "study-" - study_key = event.item.id.replace("study-", "") - # Get label text as name - label_widget = event.item.query_one(Label) - study_name = str(label_widget.renderable) # type: ignore - self.post_message(self.Selected(study_key, study_name)) - - -class SiteList(ListView): - """A list of sites for a specific study.""" - - class Selected(Message): - """Site selected message.""" - - def __init__(self, site_id: str, site_name: str) -> None: - self.site_id = site_id - self.site_name = site_name - super().__init__() - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - self.current_study_key: str | None = None - - async def load_sites(self, study_key: str) -> None: - self.current_study_key = study_key - self.clear() - self.loading = True - try: - sites = await self.sdk.sites.async_list(study_key) - items = [] - for site in sites: - site_id = getattr(site, "site_id", "no-id") - name = getattr(site, "site_name", "Unknown Site") - label = f"{name} ({site_id})" - items.append(ListItem(Label(label), id=f"site-{site_id}")) - self.extend(items) - except Exception as e: - self.app.notify(f"Error loading sites: {e}", severity="error") - finally: - self.loading = False - - def on_list_view_selected(self, event: ListView.Selected) -> None: - if event.item and event.item.id: - site_id = event.item.id.replace("site-", "") - label_widget = event.item.query_one(Label) - site_name = str(label_widget.renderable) # type: ignore - self.post_message(self.Selected(site_id, site_name)) - - -class SubjectTable(DataTable): - """A table of subjects.""" - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - self.cursor_type = "row" - self.add_columns("Subject ID", "Status", "Created") - - async def load_subjects(self, study_key: str, site_id: str) -> None: - self.clear() - self.loading = True - try: - subjects = await self.sdk.subjects.async_list(study_key) - - # Note: Subjects endpoint often returns `site_id` in the response. - rows = [] - for sub in subjects: - s_site_id = str(getattr(sub, "site_id", "")) - # Strict string comparison to filter by site - if s_site_id == str(site_id): - sub_id = getattr(sub, "subject_id", "-") - status = getattr(sub, "status", "-") - created = str(getattr(sub, "date_created", "-")) - rows.append((sub_id, status, created)) - - self.add_rows(rows) - except Exception as e: - self.app.notify(f"Error loading subjects: {e}", severity="error") - finally: - self.loading = False - - -class JobMonitor(Static): - """A widget that polls jobs.""" - - DEFAULT_CSS = """ - JobMonitor { - height: 100%; - background: $surface; - border: solid $primary; - overflow-y: auto; - } - """ - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - self.timer: Timer | None = None - self.current_study_key: str | None = None - - def on_mount(self) -> None: - self.update("Job Monitor: Select a study to view jobs.") - self.timer = self.set_interval(10.0, self.refresh_jobs, pause=True) - - def set_study(self, study_key: str) -> None: - self.current_study_key = study_key - self.update(f"Job Monitor: Loading jobs for study {study_key}...") - if self.timer: - self.timer.resume() - self.run_worker(self.refresh_jobs()) - - async def refresh_jobs(self) -> None: - if not self.current_study_key: - self.update("Job Monitor: Select a study to view jobs.") - if self.timer: - self.timer.pause() - return - - try: - # Polling jobs for the selected study - jobs = await self.sdk.jobs.async_list(self.current_study_key) - # Sort by date descending? - # Assuming jobs is a list of models. - - lines = [] - lines.append(f"[bold]Active Jobs ({len(jobs)})[/bold]") - for job in jobs[:10]: # Show last 10 - job_type = getattr(job, "job_type", "Unknown") - status = getattr(job, "state", getattr(job, "status", "Unknown")) - created = getattr(job, "date_created", "") - color = ( - "green" - if status in ("Completed", "Success") - else "yellow" if status in ("Processing", "Pending") else "red" - ) - lines.append(f"[{color}]{status}[/{color}] - {job_type} ({created})") - - self.update("\n".join(lines)) - except Exception as e: - self.update(f"[red]Error fetching jobs: {e}[/red]") - - -class LogViewer(Log): - """A log viewer widget.""" - - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.border_title = "Logs" - - -# ============================================================================= -# SCREENS -# ============================================================================= - - -class DashboardScreen(Screen): - """The main dashboard screen.""" - - CSS = """ - DashboardScreen { - layout: grid; - grid-size: 3 2; - grid-columns: 1fr 1fr 2fr; - grid-rows: 3fr 1fr; - } - - #studies-box { - grid-column: 1; - grid-row: 1; - border: solid green; - border-title: Studies; - } - - #sites-box { - grid-column: 2; - grid-row: 1; - border: solid blue; - border-title: Sites; - } - - #subjects-box { - grid-column: 3; - grid-row: 1; - border: solid magenta; - border-title: Subjects; - } - - #bottom-pane { - grid-column: 1 / 4; - grid-row: 2; - border-top: solid white; - } - """ - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - self.log_viewer = LogViewer() - - def compose(self) -> ComposeResult: - yield Container(StudyList(self.sdk, id="study_list"), id="studies-box") - yield Container(SiteList(self.sdk, id="site_list"), id="sites-box") - yield Container(SubjectTable(self.sdk, id="subject_table"), id="subjects-box") - - with TabbedContent(id="bottom-pane"): - with TabPane("Jobs"): - yield JobMonitor(self.sdk) - with TabPane("Form Builder"): - yield FormBuilderPane(self.sdk) - with TabPane("Logs"): - yield self.log_viewer - - def on_study_list_selected(self, message: StudyList.Selected) -> None: - self.log_viewer.write_line(f"Selected Study: {message.study_name}") - site_list = self.query_one(SiteList) - self.run_worker(site_list.load_sites(message.study_key)) - # Clear subjects - self.query_one(SubjectTable).clear() - - # Update Job Monitor - job_monitor = self.query_one(JobMonitor) - job_monitor.set_study(message.study_key) - - def on_site_list_selected(self, message: SiteList.Selected) -> None: - self.log_viewer.write_line(f"Selected Site: {message.site_name}") - site_list = self.query_one(SiteList) - # We need the study key from the site list or store it - study_key = site_list.current_study_key - if study_key: - subject_table = self.query_one(SubjectTable) - self.run_worker(subject_table.load_subjects(study_key, message.site_id)) - - -# ============================================================================= -# APP -# ============================================================================= - - -class ImednetTuiApp(App): - """The iMednet TUI Application.""" - - CSS = """ - Screen { - background: $surface; - } - """ - - BINDINGS = [ - ("q", "quit", "Quit"), - ("d", "toggle_dark", "Toggle Dark Mode"), - ] - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - - def on_mount(self) -> None: - self.push_screen(DashboardScreen(self.sdk)) - self.title = "iMednet Mission Control" - # Set sub-title to environment - # Use hidden attribute or public if available. SDK doesn't expose base_url publicly. - base_url = getattr(self.sdk, "_base_url", "Unknown") - self.sub_title = f"Env: {base_url}" - - # Attach logging handler - # We need to find the DashboardScreen to get the LogViewer - dashboard = self.query_one(DashboardScreen) - log_widget = dashboard.log_viewer - - handler = TextualLogHandler(log_widget) - formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - handler.setFormatter(formatter) - - # Attach to imednet logger - logger = logging.getLogger("imednet") - logger.setLevel(logging.INFO) - logger.addHandler(handler) - - # Also attach to root for broader capture if needed, but imednet should be enough - # Note: If json_logging is active, it might interfere, but adding a handler usually works. - - def action_toggle_dark(self) -> None: - self.dark = not self.dark # type: ignore - - -def run_tui(sdk: ImednetSDK) -> None: - """Run the TUI application.""" - app = ImednetTuiApp(sdk) - app.run() diff --git a/imednet/tui/form_builder.py b/imednet/tui/form_builder.py deleted file mode 100644 index 9e8547a8..00000000 --- a/imednet/tui/form_builder.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from textual import work -from textual.containers import Container, VerticalScroll -from textual.widgets import ( - Button, - Input, - Label, - Select, -) - -from imednet.form_designer import PRESETS, FormBuilder, FormDesignerClient - -if TYPE_CHECKING: - from imednet.sdk import ImednetSDK - - -class FormBuilderPane(Container): - """Pane for building and submitting forms via legacy endpoint.""" - - DEFAULT_CSS = """ - FormBuilderPane { - layout: vertical; - padding: 1; - } - FormBuilderPane Input { - margin-bottom: 1; - } - FormBuilderPane Select { - margin-bottom: 1; - } - FormBuilderPane Button { - margin-top: 1; - width: 100%; - background: $primary; - } - """ - - def __init__(self, sdk: ImednetSDK, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.sdk = sdk - - def compose(self) -> Any: - base_url = getattr(self.sdk, "_base_url", "") - - with VerticalScroll(): - yield Label("Target Environment") - yield Input(value=base_url, placeholder="Base URL", id="fb_base_url") - - yield Label("Session Credentials (from browser)") - yield Input(placeholder="PHPSESSID", id="fb_phpsessid", password=True) - yield Input(placeholder="CSRFKey", id="fb_csrf") - - yield Label("Target Context") - yield Input(placeholder="Form ID (e.g., 10351)", id="fb_form_id", type="integer") - yield Input(placeholder="Community ID (e.g., 500)", id="fb_comm_id", type="integer") - yield Input(placeholder="Next Revision (e.g., 3)", id="fb_revision", type="integer") - - yield Label("Form Definition") - # Select options must be (label, value) - options = [(k, k) for k in PRESETS.keys()] - yield Select(options, prompt="Select a Preset", id="fb_preset") - - yield Button("Build & Submit Payload", variant="primary", id="fb_submit") - - @work(exclusive=True, thread=True) - def submit_form( - self, - base_url: str, - phpsessid: str, - csrf: str, - form_id: int, - comm_id: int, - rev: int, - preset_name: str, - ) -> None: - logger = logging.getLogger("imednet") - logger.info(f"Starting Form Build: {preset_name} -> {base_url}") - - try: - # 1. Build Layout - builder = FormBuilder() - build_func = PRESETS[preset_name] - build_func(builder) - layout = builder.build() - logger.info("Layout generated successfully.") - - # 2. Submit - client = FormDesignerClient(base_url, phpsessid) - logger.info("Submitting payload to formdez_save.php...") - - response_text = client.save_form( - csrf_key=csrf, form_id=form_id, community_id=comm_id, revision=rev, layout=layout - ) - - logger.info("Submission Complete!") - # Truncate response if too long - snippet = response_text[:200] + "..." if len(response_text) > 200 else response_text - logger.info(f"Server Response: {snippet}") - - self.app.notify("Form Submitted Successfully!", severity="information") - - except Exception as e: - logger.error(f"Error submitting form: {e}") - self.app.notify(f"Error: {e}", severity="error") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "fb_submit": - # Gather inputs - base_url = self.query_one("#fb_base_url", Input).value - phpsessid = self.query_one("#fb_phpsessid", Input).value - csrf = self.query_one("#fb_csrf", Input).value - - # Inputs with type="integer" are still returning str in .value, - # validation handled by widget? - # Actually Textual's Input with type='integer' restricts input but .value is str. - # We need to parse. - try: - form_id = int(self.query_one("#fb_form_id", Input).value) - comm_id = int(self.query_one("#fb_comm_id", Input).value) - rev = int(self.query_one("#fb_revision", Input).value) - except ValueError: - self.app.notify("Please enter valid integers for IDs/Revision", severity="error") - return - - preset_sel = self.query_one("#fb_preset", Select) - if preset_sel.value == Select.BLANK: - self.app.notify("Please select a form preset", severity="error") - return - preset = str(preset_sel.value) - - if not phpsessid or not csrf: - self.app.notify("Session ID and CSRF Key are required", severity="error") - return - - self.submit_form(base_url, phpsessid, csrf, form_id, comm_id, rev, preset) diff --git a/pyproject.toml b/pyproject.toml index 4572ad0d..7074011d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,6 @@ typer = {extras = ["all"], version = "^0.15.2"} python-json-logger = "^2.0.7" urllib3 = "^2.6.3" filelock = "^3.20.3" -textual = "0.86.0" [tool.poetry.extras] pandas = ["pandas"] diff --git a/tests/unit/test_tui_migration.py b/tests/unit/test_tui_migration.py new file mode 100644 index 00000000..841ad444 --- /dev/null +++ b/tests/unit/test_tui_migration.py @@ -0,0 +1,75 @@ +import pytest +from datetime import datetime +from imednet.models.jobs import Job +from imednet.models.subjects import Subject +from imednet.form_designer.client import FormDesignerClient +from imednet.form_designer.models import Layout + +def test_job_status_properties(): + """Verify logic migrated from TUI JobMonitor.""" + # Test Success cases + j1 = Job(jobId="1", batchId="1", state="Completed") + assert j1.is_terminal is True + assert j1.is_successful is True + assert j1.is_failed is False + + j2 = Job(jobId="2", batchId="2", state="Success") + assert j2.is_successful is True + + # Test Pending cases + j3 = Job(jobId="3", batchId="3", state="Processing") + assert j3.is_terminal is False + assert j3.is_successful is False + assert j3.is_failed is False + + # Test Failure cases + j4 = Job(jobId="4", batchId="4", state="Failed") + assert j4.is_terminal is True + assert j4.is_failed is True + +def test_subject_filtering_logic(): + """Verify logic migrated from TUI SubjectTable.""" + from imednet.endpoints.subjects import SubjectsEndpoint + from unittest.mock import Mock + + # Mock data + s1 = Subject(studyKey="sk", subjectId=1, siteId=101, subjectKey="s1") + s2 = Subject(studyKey="sk", subjectId=2, siteId=102, subjectKey="s2") + s3 = Subject(studyKey="sk", subjectId=3, siteId=101, subjectKey="s3") # Matches 101 + + # Mock client and endpoint + mock_client = Mock() + mock_client.get.return_value.json.return_value = { + "data": [ + s1.model_dump(by_alias=True), + s2.model_dump(by_alias=True), + s3.model_dump(by_alias=True) + ], + "pagination": {"totalPages": 1} + } + mock_ctx = Mock() + mock_ctx.default_study_key = "sk" + + endpoint = SubjectsEndpoint(mock_client, mock_ctx) + + # Act: Filter by site 101 + filtered = endpoint.list_by_site("sk", 101) + + # Assert + assert len(filtered) == 2 + assert all(s.site_id == 101 for s in filtered) + assert filtered[0].subject_id == 1 + assert filtered[1].subject_id == 3 + +def test_form_designer_validation(): + """Verify validation logic migrated from TUI FormBuilderPane.""" + client = FormDesignerClient("http://test", "sess") + layout = Layout(pages=[]) + + # Test invalid form_id + with pytest.raises(ValueError, match="Invalid form_id"): + client.save_form("csrf", 0, 500, 1, layout) + + # Test empty CSRF + with pytest.raises(ValueError, match="CSRF Key"): + client.save_form("", 100, 500, 1, layout) diff --git a/tests/unit/tui/test_app_structure.py b/tests/unit/tui/test_app_structure.py deleted file mode 100644 index 44e237e2..00000000 --- a/tests/unit/tui/test_app_structure.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from textual.app import App - -from imednet.sdk import ImednetSDK -from imednet.tui.app import ImednetTuiApp, SiteList, StudyList - - -@pytest.fixture -def mock_sdk(): - return MagicMock(spec=ImednetSDK) - - -def test_app_instantiation(mock_sdk): - """Test that the app can be instantiated with an SDK.""" - app = ImednetTuiApp(sdk=mock_sdk) - assert app.sdk == mock_sdk - # app.title is set in on_mount, so initially it defaults to class name or similar - assert isinstance(app, App) - - -@pytest.mark.asyncio -async def test_dashboard_screen_structure(mock_sdk): - """Test that the dashboard screen has the expected widgets.""" - # We can't easily test widgets that require an active app in __init__ - # (like DataTable calling add_columns) - # without a harness. So we will just test the simpler widgets or rely on the fact that - # if the class exists and imports, it's mostly correct for a structural test. - - # StudyList and SiteList use ListView which doesn't access app in __init__ - sl = StudyList(mock_sdk) - assert sl.sdk == mock_sdk - - sitel = SiteList(mock_sdk) - assert sitel.sdk == mock_sdk - - # SubjectTable accesses app in __init__ via add_columns -> measure -> app.console - # We'd need to mock self.app or use App.run_test() context. - # Given the complexity of setting up a Textual test harness in this environment, - # and that the code is relatively simple, we will trust the import and class - # definitions for now. diff --git a/tests/unit/tui/test_form_builder.py b/tests/unit/tui/test_form_builder.py deleted file mode 100644 index df144a69..00000000 --- a/tests/unit/tui/test_form_builder.py +++ /dev/null @@ -1,20 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from imednet.tui.form_builder import FormBuilderPane - - -@pytest.fixture -def mock_sdk(): - from imednet.sdk import ImednetSDK - - return MagicMock(spec=ImednetSDK) - - -def test_form_builder_pane_instantiation(mock_sdk): - """Test that the FormBuilderPane can be instantiated.""" - pane = FormBuilderPane(sdk=mock_sdk) - assert pane.sdk == mock_sdk - # We can check if compose returns a generator/list but usually it's called by the App - # Just verifying instantiation checks imports and __init__