diff --git a/.gitignore b/.gitignore index af21800..c3276c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,72 @@ -logs/ -spikes/ +# Python .venv/ **/*.pyc +**/*.pyo +**/*.pyd __pycache__/ +**/__pycache__/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.egg + +# Environment .env +.env.* +!.env.example + +# Logs +logs/ +*.log logs/status_line.json + +# Spikes (experimental code) +spikes/ + +# Documentation generation repomix-output.md -# Tauri client build artifacts +# Node/TypeScript client client/node_modules/ +client/dist/ +client/.vite/ +client/coverage/ + +# Rust/Tauri client client/src-tauri/target/ +client/src-tauri/gen/ + +# Test artifacts +client/playwright-report/ +client/test-results/ +.pytest_cache/ + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +*~ + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*.swn +*.code-workspace +*.sublime-project +*.sublime-workspace + +# Temporary files +*.tmp +*.temp +scratch.md diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index 604bb70..1412da4 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -1,15 +1,18 @@ # NoteFlow project overview -- Purpose: Local-first meeting capture/transcription app with gRPC server + Flet client UI, with persistence, summarization, and diarization support. -- Tech stack: Python 3.12; gRPC; Flet for UI; SQLAlchemy + Alembic for persistence; asyncpg/PostgreSQL for DB; Ruff for lint; mypy/basedpyright for typing; hatchling for packaging. +- Purpose: Local-first meeting capture/transcription app with gRPC server + Tauri/React desktop client, with persistence, summarization, and diarization support. +- Tech stack: Python 3.12 (backend); gRPC; Tauri + React + TypeScript (desktop client); SQLAlchemy + Alembic for persistence; asyncpg/PostgreSQL for DB; Ruff for lint; mypy/basedpyright for typing; hatchling for packaging. - Structure: - - `src/noteflow/` main package + - `src/noteflow/` main package (Python backend) - `domain/` entities + ports - `application/` services/use-cases - `infrastructure/` audio, ASR, persistence, security, diarization - - `grpc/` proto, server, client - - `client/` Flet UI + - `grpc/` proto, server, client wrapper - `config/` settings + - `client/` Tauri + React desktop client + - `src/` React UI components and state + - `src-tauri/` Rust shell, IPC commands, gRPC client + - `e2e/` Playwright tests - `src/noteflow/infrastructure/persistence/migrations/` Alembic migrations - `tests/` mirrors package areas with `tests/fixtures/` - `docs/` specs/milestones; `spikes/` experiments; `logs/` local-only diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md index b941363..56f2977 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -2,8 +2,11 @@ - Install dev deps: `python -m pip install -e ".[dev]"` - Run gRPC server: `python -m noteflow.grpc.server --help` -- Run Flet client: `python -m noteflow.client.app --help` -- Tests: `pytest` (or `pytest -m "not integration"` to skip external services) +- Run Tauri/React client (web): `cd client && npm run dev` +- Run Tauri/React client (desktop): `cd client && npm run tauri dev` +- Backend tests: `pytest` (or `pytest -m "not integration"` to skip external services) +- Client tests: `cd client && npm run test` +- Client E2E tests: `cd client && npx playwright test` - Lint: `ruff check .` (autofix: `ruff check --fix .`) - Type check: `mypy src/noteflow` (optional: `basedpyright`) - Build wheel: `python -m build` diff --git a/AGENTS.md b/AGENTS.md index afc00ef..6427114 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,92 @@ # Repository Guidelines +## Repository Overview +- Noteflow ships a Python backend (gRPC server + domain services) and a Tauri + React desktop client. +- The gRPC API is the shared contract between backend and client; schema lives in `src/noteflow/grpc/proto/noteflow.proto`. +- Backend code is layered: domain (pure models + ports), application (use-cases), infrastructure (IO implementations). +- The repo is split into backend package (`src/noteflow/`) and frontend app (`client/`), with tests and docs alongside. +- When you touch shared contracts (proto fields/enums, DTOs), update Python, Rust, and TypeScript counterparts together. + +## Quick Orientation (Start Here) +- Backend entry point: `python -m noteflow.grpc.server` (service implementation in `src/noteflow/grpc/service.py`). +- Tauri/React client: `cd client && npm run dev` (web), or `npm run tauri dev` (desktop). +- Tauri IPC bridge: `client/src/lib/tauri.ts` (TS) <-> `client/src-tauri/src/commands/` (Rust). +- Protobuf contract and generated stubs live in `src/noteflow/grpc/proto/`. + ## Project Structure & Module Organization -- `src/noteflow/` holds the main package. Key areas include `domain/` (entities + ports), `application/` (use-cases/services), `infrastructure/` (audio, ASR, persistence, security), `grpc/` (proto, server, client), `client/` (Flet UI), and `config/` (settings). -- `src/noteflow/infrastructure/persistence/migrations/` contains Alembic migrations and templates. -- `tests/` mirrors package areas (`domain/`, `application/`, `infrastructure/`, `integration/`) with shared fixtures in `tests/fixtures/`. -- `docs/` contains specs and milestones; `spikes/` houses experiments; `logs/` is local-only. +- `src/noteflow/domain/` defines entities, value objects, and port protocols; keep this layer pure and testable. +- `src/noteflow/application/` hosts use-case services that orchestrate domain + infrastructure. +- `src/noteflow/infrastructure/` provides concrete adapters (audio, ASR, persistence, security, summarization, triggers). +- `src/noteflow/grpc/` contains the server, client wrapper, mixins, and proto conversions. +- `client/` is the Tauri/Vite React client; UI in `client/src/`, Rust shell in `client/src-tauri/`, e2e tests in `client/e2e/`. + +## Backend Architecture & Data Flow +- gRPC server entry point lives in `src/noteflow/grpc/server.py`; `NoteFlowServicer` in `src/noteflow/grpc/service.py` composes feature mixins. +- Proto <-> domain conversions and enum mappings are centralized in `src/noteflow/grpc/_mixins/converters.py`. +- Unit-of-work and repositories live under `src/noteflow/infrastructure/persistence/`, with memory fallback when no DB is configured. +- Streaming audio ingestion uses `src/noteflow/infrastructure/audio/` and ASR/VAD components in `src/noteflow/infrastructure/asr/`. +- Encryption, key storage, and secure asset handling live in `src/noteflow/infrastructure/security/`. + +## Client Architecture (Tauri + React) +- React components are in `client/src/components/`, state in `client/src/store/`, and shared UI types in `client/src/types/`. +- Tauri command calls are centralized in `client/src/lib/tauri.ts`; the Rust command handlers live in `client/src-tauri/src/commands/`. +- Rust app entry points are `client/src-tauri/src/main.rs` and `client/src-tauri/src/lib.rs`; shared state lives in `client/src-tauri/src/state/`. +- Client tests are colocated with UI code (Vitest) and end-to-end tests live in `client/e2e/` (Playwright). + +## Contracts & Sync Points (High Risk of Breakage) +- Source-of-truth API schema: `src/noteflow/grpc/proto/noteflow.proto`. +- Python gRPC stubs are checked in under `src/noteflow/grpc/proto/`; regenerate them when the proto changes. +- Rust/Tauri gRPC types are generated at build time by `client/src-tauri/build.rs`; keep Rust types aligned with proto changes. +- Frontend enums/DTOs in `client/src/types/` mirror proto enums and backend domain types; update together to avoid runtime mismatches. +- When adding or renaming RPCs, update server mixins, `src/noteflow/grpc/client.py`, and Tauri command wrappers. + +## Common Pitfalls & Change Checklist + +### Proto / API evolution +- Update `src/noteflow/grpc/proto/noteflow.proto` first; treat it as the schema source of truth. +- Regenerate Python stubs under `src/noteflow/grpc/proto/` and verify imports in `src/noteflow/grpc/`. +- Update gRPC server mixins in `src/noteflow/grpc/_mixins/` and service wiring in `src/noteflow/grpc/service.py`. +- Update the Python client wrapper in `src/noteflow/grpc/client.py`. +- Update Tauri/Rust command handlers in `client/src-tauri/src/commands/` and any Rust gRPC types. +- Update TypeScript calls in `client/src/lib/tauri.ts` and DTOs/enums in `client/src/types/`. +- Add or adjust tests in both backend and client to cover payload changes. + +### Database schema & migrations +- Update ORM models and add Alembic migrations under `src/noteflow/infrastructure/persistence/migrations/`. +- Review autogenerated migrations before applying; keep them deterministic and reversible. +- Update repository and UnitOfWork implementations when introducing new tables/relationships. +- Keep export/summarization converters in `src/noteflow/infrastructure/converters/` aligned with schema changes. + +### Client sync points (Rust + TS) +- Tauri command signatures in `client/src-tauri/src/commands/` must match TypeScript calls in `client/src/lib/tauri.ts`. +- Rust gRPC types are generated by `client/src-tauri/build.rs`; verify proto paths when moving files. +- Frontend enums in `client/src/types/` mirror proto enums; update both sides together. ## Build, Test, and Development Commands -- `python -m pip install -e ".[dev]"` installs the package and dev tools. -- `python -m noteflow.grpc.server --help` runs the gRPC server (after editable install). -- `python -m noteflow.client.app --help` runs the Flet client UI. -- `pytest` runs the full test suite; `pytest -m "not integration"` skips external-service tests. -- `ruff check .` runs linting; `ruff check --fix .` applies autofixes. -- `mypy src/noteflow` runs strict type checks; `basedpyright` is available for additional checks. -- Packaging uses hatchling; for a wheel, run `python -m build` (requires `build`). +- Backend setup/run: `python -m pip install -e ".[dev]"`, `python -m noteflow.grpc.server`. +- Backend checks: `pytest`, `pytest -m "not integration"`, `ruff check .`, `ruff check --fix .`, `mypy src/noteflow`. +- Frontend dev/test: `cd client && npm run dev`, `npm run build`, `npm run test`, `npm run typecheck`. +- Frontend lint/format: `cd client && npm run lint`, `npm run lint:fix`, `npm run format`, `npm run format:check`. +- Rust checks: `cd client && npm run lint:rs` (clippy), `npm run format:rs` (rustfmt); install tools via `rustup component add rustfmt clippy`. +- Packaging: `python -m build`. ## Coding Style & Naming Conventions -- Python 3.12, 4-space indentation, and a 100-character line length (Ruff). +- Python 3.12 with 4-space indentation and 100-character line length (Ruff). - Naming: `snake_case` for modules/functions, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants. - Keep typing explicit and compatible with strict `mypy`; generated `*_pb2.py` files are excluded from lint. +- Frontend formatting uses Prettier (single quotes, 100 char width); linting uses Biome. +- Rust formatting uses `rustfmt`; linting uses `clippy` via the client scripts. ## Testing Guidelines - Pytest with asyncio auto mode; test files `test_*.py`, functions `test_*`. - Use markers: `@pytest.mark.slow` for model-loading tests and `@pytest.mark.integration` for external services. - Integration tests may require PostgreSQL via `NOTEFLOW_DATABASE_URL`. +- React unit tests use Vitest; e2e uses Playwright from `client/e2e/`. +- Keep proto/type changes covered with at least one backend test and one client-facing test. ## Commit & Pull Request Guidelines -- The repository currently has no commit history; no established convention yet. Use Conventional Commits (e.g., `feat:`, `fix:`, `chore:`) and include a concise scope when helpful. -- PRs should describe the change, link related issues/specs, note DB or proto changes, and include UI screenshots when the Flet client changes. +- Use Conventional Commits (e.g., `feat:`, `fix:`, `chore:`) and include a concise scope when helpful. +- PRs should describe the change, link related issues/specs, note DB or proto changes, and include UI screenshots when any client UI changes. ## Configuration & Security Notes - Runtime settings come from `.env` or `NOTEFLOW_` environment variables (see `src/noteflow/config/settings.py`). diff --git a/CLAUDE.md b/CLAUDE.md index 7ca0870..bcf3928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -NoteFlow is an intelligent meeting notetaker: local-first audio capture + navigable recall + evidence-linked summaries. Client-server architecture using gRPC for bidirectional audio streaming and transcription. +NoteFlow is an intelligent meeting notetaker: local-first audio capture + navigable recall + evidence-linked summaries. It is a client-server system built around a gRPC API for bidirectional audio streaming and transcription. The repository includes: +- A Python backend (`src/noteflow/`) hosting the gRPC server, domain logic, and infrastructure adapters. +- A Tauri + React desktop client (`client/`) that uses Rust for IPC and a React UI (Vite). + +The gRPC schema is the shared contract between backend and client; keep proto changes in sync across Python, Rust, and TypeScript. + +## Quick Orientation (Start Here) + +- Backend server entry point: `python -m noteflow.grpc.server` (implementation in `src/noteflow/grpc/service.py`). +- Tauri/React client: `cd client && npm run dev` (web), `npm run tauri dev` (desktop). +- Tauri IPC bridge: TypeScript calls in `client/src/lib/tauri.ts` map to Rust commands in `client/src-tauri/src/commands/`. +- Protobuf schema and generated Python stubs live in `src/noteflow/grpc/proto/`. ## Build and Development Commands @@ -15,8 +26,13 @@ python -m pip install -e ".[dev]" # Run gRPC server python -m noteflow.grpc.server --help -# Run Flet client UI -python -m noteflow.client.app --help +# Run Tauri + React client UI +cd client +npm install +npm run dev +# Desktop Tauri dev (requires Rust toolchain) +npm run tauri dev +cd - # Tests pytest # Full suite @@ -30,6 +46,14 @@ ruff check --fix . # Autofix mypy src/noteflow # Strict type checks basedpyright # Additional type checks +# Client lint/format (from client/) +npm run lint # Biome +npm run lint:fix # Biome autofix +npm run format # Prettier +npm run format:check # Prettier check +npm run lint:rs # Clippy (Rust) +npm run format:rs # rustfmt (Rust) + # Docker development docker compose up -d postgres # PostgreSQL with health checks python scripts/dev_watch_server.py # Auto-reload server (watches src/) @@ -68,17 +92,26 @@ src/noteflow/ │ ├── export/ # Markdown/HTML export │ └── converters/ # ORM ↔ domain entity converters ├── grpc/ # Proto definitions, server, client, meeting store, modular mixins -├── client/ # Flet UI app + components (transcript, VU meter, playback, trigger mixin) └── config/ # Pydantic settings (NOTEFLOW_ env vars) ``` +Frontend (Tauri + React) lives outside the Python package: + +``` +client/ +├── src/ # React UI, state, and view components +├── src-tauri/ # Rust/Tauri shell, IPC commands, gRPC client +├── e2e/ # Playwright tests +├── package.json # Vite + test/lint scripts +└── vite.config.ts # Vite configuration +``` + **Key patterns:** - Hexagonal architecture: domain → application → infrastructure - Repository pattern with Unit of Work (`SQLAlchemyUnitOfWork`) - gRPC bidirectional streaming for audio → transcript flow - Protocol-based DI (see `domain/ports/` and infrastructure `protocols.py` files) - Modular gRPC mixins for separation of concerns (see below) -- `BackgroundWorkerMixin` for standardized thread lifecycle in components ## gRPC Mixin Architecture @@ -98,6 +131,12 @@ grpc/_mixins/ Each mixin operates on `ServicerHost` protocol, enabling clean composition in `NoteFlowServicer`. +## Client Architecture (Tauri + React) + +- React components are in `client/src/components/`, shared UI types in `client/src/types/`, and Zustand state in `client/src/store/`. +- Tauri IPC calls live in `client/src/lib/tauri.ts` and map to Rust handlers in `client/src-tauri/src/commands/`. +- Rust application entry points are `client/src-tauri/src/main.rs` and `client/src-tauri/src/lib.rs`; shared runtime state is in `client/src-tauri/src/state/`. + ## Database PostgreSQL with pgvector extension. Async SQLAlchemy with asyncpg driver. @@ -116,6 +155,7 @@ Connection via `NOTEFLOW_DATABASE_URL` env var or settings. - Markers: `@pytest.mark.slow` (model loading), `@pytest.mark.integration` (external services) - Integration tests use testcontainers for PostgreSQL - Asyncio auto-mode enabled +- React unit tests use Vitest; e2e tests use Playwright in `client/e2e/`. ## Proto/gRPC @@ -130,17 +170,46 @@ python -m grpc_tools.protoc -I src/noteflow/grpc/proto \ src/noteflow/grpc/proto/noteflow.proto ``` +**Sync points (high risk of breakage):** +- Rust gRPC types are generated at build time by `client/src-tauri/build.rs`. Keep Rust DTOs aligned with proto changes. +- Frontend enums/DTOs in `client/src/types/` mirror proto enums and backend domain types; update these when proto enums change. +- When adding or renaming RPCs, update: server mixins, `src/noteflow/grpc/client.py`, Tauri command handlers, and `client/src/lib/tauri.ts`. + +## Common Pitfalls & Change Checklist + +### Proto / API evolution +- Update the schema in `src/noteflow/grpc/proto/noteflow.proto` first; treat it as the source of truth. +- Regenerate Python stubs (`*_pb2.py`, `*_pb2_grpc.py`) and verify imports still align in `src/noteflow/grpc/`. +- Ensure the gRPC server mixins in `src/noteflow/grpc/_mixins/` implement new/changed RPCs. +- Update `src/noteflow/grpc/client.py` (Python client wrapper) to match the RPC signature and response types. +- Update Tauri/Rust command handlers (`client/src-tauri/src/commands/`) and any Rust gRPC types used by commands. +- Update TypeScript wrappers in `client/src/lib/tauri.ts` and shared DTOs/enums in `client/src/types/`. +- Add/adjust tests on both sides (backend unit/integration + client unit tests) when changing payload shapes. + +### Database schema & migrations +- Schema changes belong in `src/noteflow/infrastructure/persistence/` plus an Alembic migration in `src/noteflow/infrastructure/persistence/migrations/`. +- Use `alembic revision --autogenerate` only after updating models; review the migration for correctness. +- Keep `NOTEFLOW_DATABASE_URL` in mind when running integration tests; default behavior may fall back to memory storage. +- Update repository/UnitOfWork implementations if new tables or relations are introduced. +- If you add fields used by export/summarization, ensure converters in `infrastructure/converters/` are updated too. + +### Client sync points (Rust + TS) +- Tauri IPC surfaces (Rust commands) must match the TypeScript calls in `client/src/lib/tauri.ts`. +- Rust gRPC types are generated by `client/src-tauri/build.rs`; verify the proto path if you move proto files. +- Frontend enums in `client/src/types/` mirror backend/proto enums; keep them aligned to avoid silent UI bugs. + ## Code Style - Python 3.12+, 100-char line length - Strict mypy (allow `type: ignore[code]` only with comment explaining why) - Ruff for linting (E, W, F, I, B, C4, UP, SIM, RUF) - Module soft limit 500 LoC, hard limit 750 LoC +- Frontend formatting uses Prettier (single quotes, 100 char width); linting uses Biome. +- Rust formatting uses `rustfmt`; linting uses `clippy` via the client scripts. ## Spikes (De-risking Experiments) `spikes/` contains validated platform experiments with `FINDINGS.md`: -- `spike_01_ui_tray_hotkeys/` - Flet + pystray + pynput (requires X11) - `spike_02_audio_capture/` - sounddevice + PortAudio - `spike_03_asr_latency/` - faster-whisper benchmarks (0.05x real-time) - `spike_04_encryption/` - keyring + AES-GCM (826 MB/s throughput) @@ -227,14 +296,6 @@ python -m grpc_tools.protoc -I src/noteflow/grpc/proto \ |----------|---------| | `parse_calendar_events()` | Parse events from config/env | -### Client Mixins (`client/components/`) - -| Class | Purpose | -|-------|---------| -| `BackgroundWorkerMixin` | Thread lifecycle: `_start_worker()`, `_stop_worker()`, `_should_run()` | -| `AsyncOperationMixin[T]` | Async ops with state: `run_async_operation()` | -| `TriggerMixin` | Trigger signal polling | - ### Recovery Service (`application/services/recovery_service.py`) | Method | Purpose | diff --git a/README.md b/README.md index e69de29..c2df106 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,100 @@ +# NoteFlow + +NoteFlow is an intelligent meeting notetaker: local-first audio capture, navigable recall, and evidence-linked summaries. It uses a gRPC API for bidirectional audio streaming and transcription. + +## What's in this repo + +- Python backend (gRPC server + domain services): `src/noteflow/` +- Tauri + React desktop client (Vite): `client/` + +## Prerequisites + +- Python 3.12+ +- Node.js 18+ (for the Tauri/React client) +- Rust toolchain (only if running the Tauri desktop app) +- Docker (optional, for containerized dev) + +## Local development + +### 1) Backend server + +```bash +python -m pip install -e ".[dev]" +python -m noteflow.grpc.server +``` + +Optional: run with autoreload (watches `src/` and `alembic.ini`): + +```bash +python scripts/dev_watch_server.py +``` + +By default the server listens on `localhost:50051`. + +If you want PostgreSQL persistence, set `NOTEFLOW_DATABASE_URL` in your environment or `.env` (see `example.env`). + +### 2) Tauri + React client + +```bash +cd client +npm install + +# Web (Vite dev server) +npm run dev + +# Desktop (Tauri dev) +npm run tauri dev +``` + +The Tauri desktop app requires a working Rust toolchain. + +## Container-based development + +The repository includes a `compose.yaml` with a server container (and a commented-out Postgres service). + +### Option A: Run the server in Docker, clients locally + +1) Create a `.env` file from `example.env` and set any needed settings. +2) Start the server container: + +```bash +docker compose up -d server +``` + +The server will expose `50051` on the host; point your client at `localhost:50051`. + +### Option B: Enable PostgreSQL in Docker + +`compose.yaml` includes a commented `db` service using `pgvector/pgvector:pg15`. To use it: + +1) Uncomment the `db` service and `depends_on`/`environment` lines in `compose.yaml`. +2) Set `NOTEFLOW_DATABASE_URL` to the container URL (example): + +``` +postgresql+asyncpg://noteflow:noteflow@db:5432/noteflow +``` + +3) Start services: + +```bash +docker compose up -d +``` + +## Common commands + +```bash +# Backend +pytest +pytest -m "not integration" +ruff check . +mypy src/noteflow + +# Client (from client/) +npm run lint +npm run format +npm run test +``` + +## Configuration + +Runtime settings are read from `.env` and `NOTEFLOW_` environment variables. See `src/noteflow/config/settings.py` and `example.env`. diff --git a/client/.prettierrc.json b/client/.prettierrc.json new file mode 100644 index 0000000..5ac85e2 --- /dev/null +++ b/client/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "singleQuote": true +} diff --git a/client/biome.json b/client/biome.json new file mode 100644 index 0000000..a25324f --- /dev/null +++ b/client/biome.json @@ -0,0 +1,11 @@ +{ + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": false + } +} diff --git a/client/e2e/annotations.spec.ts b/client/e2e/annotations.spec.ts new file mode 100644 index 0000000..28cce05 --- /dev/null +++ b/client/e2e/annotations.spec.ts @@ -0,0 +1,184 @@ +import { test, expect } from '@playwright/test'; + +/** + * Annotation E2E Tests + * + * Tests the annotation toolbar and annotation display + * functionality within transcript segments. + */ + +test.describe('Annotation Toolbar', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + }); + + test('should show annotation buttons on segment hover', async ({ page }) => { + // Find transcript segments if they exist + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + const firstSegment = segments.first(); + await firstSegment.hover(); + + // Toolbar should appear on hover + await page.waitForTimeout(100); + const toolbar = firstSegment.locator('[class*="group-hover:block"]'); + // Toolbar may or may not be visible depending on meeting state + } + }); + + test('should have action item button', async ({ page }) => { + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + const actionItemButton = page.getByTitle('Action Item'); + // May or may not be visible depending on meeting state + } + }); + + test('should have decision button', async ({ page }) => { + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + const decisionButton = page.getByTitle('Decision'); + // May or may not be visible depending on meeting state + } + }); + + test('should have note button', async ({ page }) => { + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + const noteButton = page.getByTitle('Note'); + // May or may not be visible depending on meeting state + } + }); + + test('should have risk button', async ({ page }) => { + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + const riskButton = page.getByTitle('Risk'); + // May or may not be visible depending on meeting state + } + }); +}); + +test.describe('Annotation Display', () => { + test('should show annotation badges on segments', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Annotation badges would appear on segments with annotations + const annotationBadges = page.locator('[class*="inline-flex items-center gap-1"]'); + // Will be visible only if segments have annotations + }); + + test('should show annotation type icons', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Look for annotation type indicators + const annotationIcons = page.locator('svg'); + // SVG icons used throughout the interface + }); +}); + +test.describe('Annotation Note Input', () => { + test('should show text input for note annotations', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + + const noteButton = page.getByTitle('Note'); + if (await noteButton.isVisible()) { + await noteButton.click(); + + // Input field should appear + const noteInput = page.getByPlaceholder('Add note...'); + if (await noteInput.isVisible()) { + await expect(noteInput).toBeVisible(); + } + } + } + }); + + test('should close note input on Escape', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + + const noteButton = page.getByTitle('Note'); + if (await noteButton.isVisible()) { + await noteButton.click(); + + const noteInput = page.getByPlaceholder('Add note...'); + if (await noteInput.isVisible()) { + await page.keyboard.press('Escape'); + // Input should close + await page.waitForTimeout(100); + } + } + } + }); +}); + +test.describe('Annotation Removal', () => { + test('should have remove button on annotation badges', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Annotation badges have X buttons for removal + const removeButtons = page.locator('[class*="h-3 w-3"]'); + // Will be visible only if annotations exist + }); +}); + +test.describe('Annotation Accessibility', () => { + test('should have accessible toolbar buttons', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Toolbar buttons should have titles + const buttonsWithTitles = page.locator('button[title]'); + const count = await buttonsWithTitles.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + test('should support keyboard interaction for note input', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const segments = page.locator('[class*="group rounded-lg"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + + const noteButton = page.getByTitle('Note'); + if (await noteButton.isVisible()) { + await noteButton.click(); + + const noteInput = page.getByPlaceholder('Add note...'); + if (await noteInput.isVisible()) { + // Should be focusable and typable + await noteInput.focus(); + await page.keyboard.type('Test note'); + await expect(noteInput).toHaveValue('Test note'); + } + } + } + }); +}); diff --git a/client/e2e/app.spec.ts b/client/e2e/app.spec.ts index 77253ce..04db5ce 100644 --- a/client/e2e/app.spec.ts +++ b/client/e2e/app.spec.ts @@ -24,9 +24,9 @@ test.describe('Application', () => { }); test('should have navigation buttons', async ({ page }) => { - await expect(page.getByRole('button', { name: 'Recording' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Review' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Recording', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Library' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); }); }); @@ -36,14 +36,14 @@ test.describe('Navigation', () => { }); test('should switch to Recording view', async ({ page }) => { - await page.getByRole('button', { name: 'Recording' }).click(); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); // Recording view should be active - await expect(page.getByRole('button', { name: 'Recording' })).toHaveClass(/active|selected|bg-/); + await expect(page.getByRole('button', { name: 'Recording', exact: true })).toHaveClass(/active|selected|bg-/); }); - test('should switch to Review view', async ({ page }) => { - await page.getByRole('button', { name: 'Review' }).click(); - await expect(page.getByRole('button', { name: 'Review' })).toHaveClass(/active|selected|bg-/); + test('should switch to Settings view', async ({ page }) => { + await page.getByRole('button', { name: 'Settings' }).click(); + await expect(page.getByRole('button', { name: 'Settings' })).toHaveClass(/active|selected|bg-/); }); test('should switch to Library view', async ({ page }) => { @@ -55,7 +55,7 @@ test.describe('Navigation', () => { test.describe('Recording View', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); - await page.getByRole('button', { name: 'Recording' }).click(); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); }); test('should show VU meter when available', async ({ page }) => { diff --git a/client/e2e/keyboard-navigation.spec.ts b/client/e2e/keyboard-navigation.spec.ts new file mode 100644 index 0000000..f5a9677 --- /dev/null +++ b/client/e2e/keyboard-navigation.spec.ts @@ -0,0 +1,303 @@ +import { test, expect } from '@playwright/test'; + +/** + * Keyboard Navigation E2E Tests + * + * Comprehensive tests for keyboard accessibility across + * all views and components. + */ + +test.describe('Global Keyboard Navigation', () => { + test('should start with focus on interactive element', async ({ page }) => { + await page.goto('/'); + + // Tab should move focus to first interactive element + await page.keyboard.press('Tab'); + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + }); + + test('should navigate through header buttons', async ({ page }) => { + await page.goto('/'); + + // Tab through header navigation + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Tab'); + } + + // Should be able to reach navigation buttons + const navButtons = page.getByRole('button'); + const count = await navButtons.count(); + expect(count).toBeGreaterThan(0); + }); + + test('should activate buttons with Enter key', async ({ page }) => { + await page.goto('/'); + + // Focus on Library button and activate + const libraryButton = page.getByRole('button', { name: 'Library' }); + await libraryButton.focus(); + await page.keyboard.press('Enter'); + + // Should switch to library view + await expect(libraryButton).toHaveClass(/bg-primary/); + }); + + test('should activate buttons with Space key', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByRole('button', { name: 'Settings' }); + await settingsButton.focus(); + await page.keyboard.press('Space'); + + await expect(settingsButton).toHaveClass(/bg-primary/); + }); +}); + +test.describe('Recording View Keyboard Navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + }); + + test('should navigate to recording controls', async ({ page }) => { + // Tab through to find recording controls + for (let i = 0; i < 10; i++) { + await page.keyboard.press('Tab'); + } + + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + }); + + test('should navigate to playback slider if present', async ({ page }) => { + const slider = page.getByRole('slider'); + + if (await slider.isVisible()) { + await slider.focus(); + await expect(slider).toBeFocused(); + + // Arrow keys should change value + await page.keyboard.press('ArrowRight'); + // Value may change depending on initial state + } + }); +}); + +test.describe('Library View Keyboard Navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + }); + + test('should focus search input on Tab', async ({ page }) => { + const searchInput = page.getByPlaceholder('Search meetings...'); + + // Tab until search is focused + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Tab'); + if (await searchInput.evaluate((el) => el === document.activeElement)) { + break; + } + } + + // Should be able to type in search + await searchInput.focus(); + await page.keyboard.type('test'); + await expect(searchInput).toHaveValue('test'); + }); + + test('should navigate to refresh button', async ({ page }) => { + const refreshButton = page.getByRole('button', { name: /Refresh/i }); + + if (await refreshButton.isVisible()) { + await refreshButton.focus(); + await expect(refreshButton).toBeFocused(); + } + }); + + test('should navigate through meeting cards', async ({ page }) => { + const meetingCards = page.locator('[class*="cursor-pointer"]'); + + if ((await meetingCards.count()) > 0) { + // Tab through meeting cards + for (let i = 0; i < 10; i++) { + await page.keyboard.press('Tab'); + } + + const focused = page.locator(':focus'); + const isVisible = await focused.isVisible(); + expect(isVisible).toBe(true); + } + }); +}); + +test.describe('Settings View Keyboard Navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Settings' }).click(); + }); + + test('should navigate through settings fields', async ({ page }) => { + // Tab through settings form + for (let i = 0; i < 5; i++) { + await page.keyboard.press('Tab'); + } + + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + }); + + test('should allow text input in fields', async ({ page }) => { + const textInputs = page.locator('input[type="text"]'); + + if ((await textInputs.count()) > 0) { + const firstInput = textInputs.first(); + await firstInput.focus(); + await page.keyboard.type('test value'); + + const value = await firstInput.inputValue(); + expect(value).toContain('test value'); + } + }); + + test('should navigate checkboxes', async ({ page }) => { + const checkboxes = page.locator('input[type="checkbox"]'); + + if ((await checkboxes.count()) > 0) { + const firstCheckbox = checkboxes.first(); + await firstCheckbox.focus(); + + // Space should toggle checkbox + const initialState = await firstCheckbox.isChecked(); + await page.keyboard.press('Space'); + const newState = await firstCheckbox.isChecked(); + + expect(newState).not.toBe(initialState); + } + }); +}); + +test.describe('Dialog Keyboard Navigation', () => { + test('should close settings dialog with Escape', async ({ page }) => { + await page.goto('/'); + + // Settings view functions as a page, not a dialog + await page.getByRole('button', { name: 'Settings' }).click(); + + // Escape should return to previous view or be handled + await page.keyboard.press('Escape'); + + // May or may not change view depending on implementation + }); + + test('should trap focus in modal dialogs', async ({ page }) => { + await page.goto('/'); + + // If there's a trigger dialog, it should trap focus + const dialog = page.getByRole('dialog'); + + if (await dialog.isVisible()) { + // Tab should cycle within dialog + for (let i = 0; i < 20; i++) { + await page.keyboard.press('Tab'); + const focused = page.locator(':focus'); + // Focus should remain inside dialog + } + } + }); +}); + +test.describe('Focus Visibility', () => { + test('should show visible focus indicators', async ({ page }) => { + await page.goto('/'); + + // Tab to first button + await page.keyboard.press('Tab'); + + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + + // Focus ring should be visible (CSS outline or ring) + // This is a visual check - Playwright can verify element is focused + }); + + test('should maintain focus after view switch', async ({ page }) => { + await page.goto('/'); + + // Switch views and verify focus management + await page.getByRole('button', { name: 'Library' }).click(); + await page.keyboard.press('Tab'); + + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + }); +}); + +test.describe('Arrow Key Navigation', () => { + test('should navigate slider with arrow keys', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const slider = page.getByRole('slider'); + + if (await slider.isVisible()) { + await slider.focus(); + const initialValue = await slider.inputValue(); + + await page.keyboard.press('ArrowRight'); + const newValue = await slider.inputValue(); + + // Value should change (increase) + expect(parseInt(newValue)).toBeGreaterThanOrEqual(parseInt(initialValue)); + } + }); +}); + +test.describe('Skip Links', () => { + test('should skip to main content if skip link exists', async ({ page }) => { + await page.goto('/'); + + // Many apps have skip links as first focusable element + await page.keyboard.press('Tab'); + + const focused = page.locator(':focus'); + const text = await focused.textContent(); + + // Skip link would have text like "Skip to content" or "Skip to main" + // Not all apps implement this + }); +}); + +test.describe('Form Navigation', () => { + test('should navigate form fields in order', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Settings' }).click(); + + const inputs = page.locator('input'); + const inputCount = await inputs.count(); + + if (inputCount > 1) { + // Tab through inputs in order + const firstInput = inputs.first(); + await firstInput.focus(); + await page.keyboard.press('Tab'); + + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + } + }); + + test('should submit form with Enter', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + const searchInput = page.getByPlaceholder('Search meetings...'); + await searchInput.focus(); + await searchInput.fill('test search'); + await page.keyboard.press('Enter'); + + // Search should be applied (enter may or may not submit) + await expect(searchInput).toHaveValue('test search'); + }); +}); diff --git a/client/e2e/library.spec.ts b/client/e2e/library.spec.ts new file mode 100644 index 0000000..1c2b86b --- /dev/null +++ b/client/e2e/library.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; + +/** + * Meeting Library E2E Tests + * + * Tests the meeting library view including search, selection, + * export, and delete functionality. + */ + +test.describe('Meeting Library', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + }); + + test('should display library view', async ({ page }) => { + // Library view should be visible + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); + + test('should show search input', async ({ page }) => { + const searchInput = page.getByPlaceholder('Search meetings...'); + await expect(searchInput).toBeVisible(); + }); + + test('should show refresh button', async ({ page }) => { + const refreshButton = page.getByRole('button', { name: /refresh/i }); + await expect(refreshButton).toBeVisible(); + }); + + test('should filter meetings on search input', async ({ page }) => { + const searchInput = page.getByPlaceholder('Search meetings...'); + await searchInput.fill('test'); + + // Should filter meetings (or show "No matching meetings found" if none match) + await page.waitForTimeout(300); + const content = page.locator('main'); + await expect(content).toBeVisible(); + }); + + test('should clear search on empty input', async ({ page }) => { + const searchInput = page.getByPlaceholder('Search meetings...'); + await searchInput.fill('test'); + await searchInput.fill(''); + + // Should show all meetings or empty state + await page.waitForTimeout(300); + const content = page.locator('main'); + await expect(content).toBeVisible(); + }); +}); + +test.describe('Library Empty State', () => { + test('should show empty state message when no meetings', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + // May show "No meetings yet" or meeting list + const emptyMessage = page.getByText(/No meetings yet|No matching meetings/); + const meetingCards = page.locator('[class*="cursor-pointer"]'); + + const hasEmpty = await emptyMessage.isVisible().catch(() => false); + const hasMeetings = (await meetingCards.count()) > 0; + + expect(hasEmpty || hasMeetings).toBe(true); + }); +}); + +test.describe('Library Meeting Actions', () => { + test('should show export button on meeting cards', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + // Find meeting cards if they exist + const meetingCards = page.locator('[class*="cursor-pointer"]').first(); + + if (await meetingCards.isVisible()) { + // Export button should be visible on hover + await meetingCards.hover(); + const exportButton = page.getByTitle('Export as Markdown'); + // May or may not be visible depending on CSS hover state + await page.waitForTimeout(100); + } + }); + + test('should show delete button on meeting cards', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + const meetingCards = page.locator('[class*="cursor-pointer"]').first(); + + if (await meetingCards.isVisible()) { + await meetingCards.hover(); + const deleteButton = page.getByTitle('Delete meeting'); + await page.waitForTimeout(100); + } + }); +}); + +test.describe('Library Keyboard Navigation', () => { + test('should focus search input first on Tab', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + // Focus the search input directly + const searchInput = page.getByPlaceholder('Search meetings...'); + await searchInput.focus(); + await expect(searchInput).toBeFocused(); + }); + + test('should allow typing in search when focused', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + const searchInput = page.getByPlaceholder('Search meetings...'); + await searchInput.focus(); + await page.keyboard.type('meeting'); + + await expect(searchInput).toHaveValue('meeting'); + }); +}); + +test.describe('Library Accessibility', () => { + test('should have accessible search input', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + const searchInput = page.getByPlaceholder('Search meetings...'); + await expect(searchInput).toHaveAttribute('type', 'text'); + }); + + test('should have accessible buttons', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Library' }).click(); + + const refreshButton = page.getByRole('button', { name: /refresh/i }); + await expect(refreshButton).toBeVisible(); + }); +}); diff --git a/client/e2e/playback.spec.ts b/client/e2e/playback.spec.ts new file mode 100644 index 0000000..b7838e6 --- /dev/null +++ b/client/e2e/playback.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; + +/** + * Playback E2E Tests + * + * Tests the playback controls UX including play/pause/stop + * and seek functionality. + */ + +test.describe('Playback Controls', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Recording view contains playback controls + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + }); + + test('should show playback controls in Review view', async ({ page }) => { + // Playback controls area should exist + const playbackArea = page.locator('input[type="range"]'); + // May or may not be visible depending on whether a meeting is loaded + if (await playbackArea.isVisible()) { + await expect(playbackArea).toBeVisible(); + } + }); + + test('should display time in correct format', async ({ page }) => { + // Look for time display (00:00 format) + const timeDisplay = page.getByText(/\d{1,2}:\d{2}/); + // At least one time display should be present if playback is visible + const count = await timeDisplay.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); +}); + +test.describe('Playback Keyboard Shortcuts', () => { + test('should be keyboard navigable', async ({ page }) => { + await page.goto('/'); + + // Tab through interface + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + // Something should be focused + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + }); + + test('should support space bar for play/pause toggle', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Focus on a playback button if present + const playButton = page.locator('button').filter({ has: page.locator('svg') }).first(); + if (await playButton.isVisible()) { + await playButton.focus(); + // Space should toggle (in a real app with Tauri) + await page.keyboard.press('Space'); + } + }); +}); + +test.describe('Seek Bar Interaction', () => { + test('should have accessible slider', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const slider = page.getByRole('slider'); + if (await slider.isVisible()) { + await expect(slider).toBeVisible(); + // Should have min/max attributes + await expect(slider).toHaveAttribute('min', '0'); + } + }); + + test('should update position display on drag', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const slider = page.getByRole('slider'); + if (await slider.isVisible()) { + // Get initial value + const initialValue = await slider.inputValue(); + + // Simulate drag + await slider.fill('30'); + + // Value should change + const newValue = await slider.inputValue(); + expect(newValue).toBe('30'); + } + }); +}); diff --git a/client/e2e/recording.spec.ts b/client/e2e/recording.spec.ts new file mode 100644 index 0000000..9c9ac77 --- /dev/null +++ b/client/e2e/recording.spec.ts @@ -0,0 +1,112 @@ +import { test, expect } from '@playwright/test'; + +/** + * Recording E2E Tests + * + * Tests the recording flow including start/stop and + * connection state handling. + */ + +test.describe('Recording View', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + }); + + test('should show recording controls', async ({ page }) => { + // Should have either Start Recording or Stop button + const startButton = page.getByRole('button', { name: /start recording/i }); + const stopButton = page.getByRole('button', { name: /stop/i }); + + const hasStart = await startButton.isVisible(); + const hasStop = await stopButton.isVisible(); + + // One of them should be visible + expect(hasStart || hasStop).toBe(true); + }); + + test('should show VU meter', async ({ page }) => { + // VU meter should display dB level + const vuMeter = page.getByText(/dB/); + await expect(vuMeter).toBeVisible(); + }); + + test('should disable recording when disconnected', async ({ page }) => { + // If disconnected, start button should be disabled + const disconnected = await page.getByText('Disconnected').isVisible(); + + if (disconnected) { + const startButton = page.getByRole('button', { name: /start recording/i }); + if (await startButton.isVisible()) { + await expect(startButton).toBeDisabled(); + } + } + }); +}); + +test.describe('Recording Timer', () => { + test('should show timer when recording', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // If recording is active, timer should show + const stopButton = page.getByRole('button', { name: /stop/i }); + if (await stopButton.isVisible()) { + // Look for time display (could be 00:00 or similar format) + const timeDisplay = page.getByText(/\d{1,2}:\d{2}(:\d{2})?/); + await expect(timeDisplay.first()).toBeVisible(); + } + }); +}); + +test.describe('Recording Start Flow', () => { + test('should show loading state when starting', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const startButton = page.getByRole('button', { name: /start recording/i }); + + if (await startButton.isVisible() && !(await startButton.isDisabled())) { + // Click should trigger loading state + await startButton.click(); + + // Button should become disabled during loading + // In web mode without Tauri, this will likely fail quickly + // but we test the UI response + await page.waitForTimeout(100); + } + }); +}); + +test.describe('Recording Stop Flow', () => { + test('should show stop button when recording', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // If we're recording, stop button should be visible + const stopButton = page.getByRole('button', { name: /stop/i }); + if (await stopButton.isVisible()) { + await expect(stopButton).toBeEnabled(); + } + }); +}); + +test.describe('VU Meter Visual Feedback', () => { + test('should display level indicator', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // VU meter should have segments + const vuContainer = page.locator('.h-4'); // Segment height class + const count = await vuContainer.count(); + expect(count).toBeGreaterThan(0); + }); + + test('should show dB value', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const dbDisplay = page.getByText(/-?\d+ dB/); + await expect(dbDisplay).toBeVisible(); + }); +}); diff --git a/client/e2e/settings.spec.ts b/client/e2e/settings.spec.ts new file mode 100644 index 0000000..038aabd --- /dev/null +++ b/client/e2e/settings.spec.ts @@ -0,0 +1,209 @@ +import { test, expect } from '@playwright/test'; + +/** + * Settings E2E Tests + * + * Tests the settings panel UX including preferences, + * audio device selection, and connection settings. + */ + +test.describe('Settings Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('should open settings dialog', async ({ page }) => { + // Look for settings button (gear icon) + const settingsButton = page.getByTitle(/settings/i); + + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + // Settings dialog should open + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + } + }); + + test('should close settings with Escape key', async ({ page }) => { + const settingsButton = page.getByTitle(/settings/i); + + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Press Escape to close + await page.keyboard.press('Escape'); + + await expect(dialog).not.toBeVisible(); + } + }); + + test('should close settings with close button', async ({ page }) => { + const settingsButton = page.getByTitle(/settings/i); + + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Find and click close button + const closeButton = dialog.locator('button').filter({ hasText: /close|cancel|×/i }).first(); + if (await closeButton.isVisible()) { + await closeButton.click(); + await expect(dialog).not.toBeVisible(); + } + } + }); +}); + +test.describe('Connection Settings', () => { + test('should show server address field', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + // Look for server URL input + const serverInput = page.getByLabel(/server/i); + if (await serverInput.isVisible()) { + await expect(serverInput).toBeVisible(); + } + } + }); + + test('should validate server address format', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + const serverInput = page.getByLabel(/server/i); + if (await serverInput.isVisible()) { + // Enter invalid address + await serverInput.fill('not-a-valid-url'); + + // Look for validation error + const error = page.getByText(/invalid|error/i); + // May or may not show error depending on validation timing + } + } + }); +}); + +test.describe('Audio Device Settings', () => { + test('should show device selection dropdown', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + // Look for audio device section + const audioSection = page.getByText(/audio|microphone|device/i); + if (await audioSection.first().isVisible()) { + await expect(audioSection.first()).toBeVisible(); + } + } + }); +}); + +test.describe('Data Directory Settings', () => { + test('should show data directory field', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + // Look for data directory input + const dataInput = page.getByLabel(/data|directory|folder/i); + if (await dataInput.first().isVisible()) { + await expect(dataInput.first()).toBeVisible(); + } + } + }); +}); + +test.describe('Settings Persistence', () => { + test('should preserve settings after close and reopen', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + // Open settings + await settingsButton.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Find a text input + const input = dialog.locator('input[type="text"]').first(); + if (await input.isVisible()) { + const originalValue = await input.inputValue(); + + // Close and reopen + await page.keyboard.press('Escape'); + await settingsButton.click(); + + // Value should be preserved + const newValue = await input.inputValue(); + expect(newValue).toBe(originalValue); + } + } + }); +}); + +test.describe('Settings Accessibility', () => { + test('should have proper labels for inputs', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + const dialog = page.getByRole('dialog'); + if (await dialog.isVisible()) { + // All inputs should have labels + const inputs = dialog.locator('input'); + const count = await inputs.count(); + + for (let i = 0; i < count; i++) { + const input = inputs.nth(i); + // Should have either aria-label or associated label + const hasLabel = + (await input.getAttribute('aria-label')) || + (await input.getAttribute('id')); + // Not all inputs require labels, so we just check there's some accessibility + } + } + } + }); + + test('should be navigable with Tab key', async ({ page }) => { + await page.goto('/'); + + const settingsButton = page.getByTitle(/settings/i); + if (await settingsButton.isVisible()) { + await settingsButton.click(); + + const dialog = page.getByRole('dialog'); + if (await dialog.isVisible()) { + // Tab through elements + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + // Something should be focused inside dialog + const focused = dialog.locator(':focus'); + const count = await focused.count(); + expect(count).toBe(1); + } + } + }); +}); diff --git a/client/e2e/summary.spec.ts b/client/e2e/summary.spec.ts new file mode 100644 index 0000000..f271d3e --- /dev/null +++ b/client/e2e/summary.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@playwright/test'; + +/** + * Summary Panel E2E Tests + * + * Tests the summary generation, display, and + * citation interaction features. + */ + +test.describe('Summary Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + }); + + test('should display summary heading', async ({ page }) => { + const summaryHeading = page.getByRole('heading', { name: 'Summary' }); + await expect(summaryHeading).toBeVisible(); + }); + + test('should show generate button', async ({ page }) => { + const generateButton = page.getByRole('button', { name: /Generate|Regenerate/i }); + // Button may or may not be visible depending on meeting state + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); + + test('should show empty state when no summary', async ({ page }) => { + // Either show prompt to generate or record first + const emptyMessage = page.getByText(/Record a meeting|Click Generate|No transcript/); + const summaryContent = page.getByText(/Executive Summary/); + + const hasEmpty = await emptyMessage.first().isVisible().catch(() => false); + const hasSummary = await summaryContent.isVisible().catch(() => false); + + expect(hasEmpty || hasSummary).toBe(true); + }); +}); + +test.describe('Summary Generation', () => { + test('should disable generate button when no segments', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const generateButton = page.getByRole('button', { name: /Generate/i }); + + if (await generateButton.isVisible()) { + // Button should be disabled when no segments + const isDisabled = await generateButton.isDisabled(); + // May or may not be disabled depending on state + } + }); + + test('should show loading state during generation', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Loading indicator would appear during generation + const loadingIndicator = page.locator('[class*="animate-spin"]'); + // May or may not be visible + }); +}); + +test.describe('Summary Display', () => { + test('should display executive summary section', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Executive summary heading should exist in structure + const execSummaryLabel = page.getByText('Executive Summary'); + // Will be visible only if summary exists + }); + + test('should display key points section when available', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const keyPointsLabel = page.getByText('Key Points'); + // Will be visible only if summary has key points + }); + + test('should display action items section when available', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const actionItemsLabel = page.getByText('Action Items'); + // Will be visible only if summary has action items + }); +}); + +test.describe('Summary Citations', () => { + test('should show citation links on key points', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Citation links have format [#N] + const citationLinks = page.getByText(/\[#\d+\]/); + // Will be visible only if summary has citations + }); + + test('should make citation links clickable', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const citationLinks = page.locator('button').filter({ hasText: /\[#\d+\]/ }); + + if ((await citationLinks.count()) > 0) { + // Citation links should be interactive + await expect(citationLinks.first()).toBeVisible(); + } + }); +}); + +test.describe('Summary Action Items', () => { + test('should show assignee badges on action items', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Action items may have assignee badges + const assigneeBadges = page.locator('[class*="bg-primary/20"]'); + // Will be visible only if summary has action items with assignees + }); + + test('should show priority badges on action items', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Priority badges: High (red), Medium (yellow), Low (blue) + const priorityBadges = page.getByText(/High|Medium|Low/); + // Will be visible only if summary has prioritized action items + }); +}); + +test.describe('Summary Error Handling', () => { + test('should display error message on generation failure', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Error message would appear in red + const errorMessage = page.locator('[class*="bg-red"]'); + // May or may not be visible depending on state + }); +}); + +test.describe('Summary Scrolling', () => { + test('should have scrollable summary content', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const scrollableContent = page.locator('[class*="overflow-auto"]'); + await expect(scrollableContent.first()).toBeVisible(); + }); +}); + +test.describe('Summary Accessibility', () => { + test('should have proper heading hierarchy', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Summary heading should be h2 + const h2Elements = page.locator('h2'); + const count = await h2Elements.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + test('should be keyboard navigable', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Summary heading should be visible + const summaryHeading = page.getByRole('heading', { name: 'Summary' }); + await expect(summaryHeading).toBeVisible(); + + // Main content area should be navigable + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); +}); diff --git a/client/e2e/transcript.spec.ts b/client/e2e/transcript.spec.ts new file mode 100644 index 0000000..5593429 --- /dev/null +++ b/client/e2e/transcript.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from '@playwright/test'; + +/** + * Transcript View E2E Tests + * + * Tests the transcript display, segment interaction, + * and annotation features. + */ + +test.describe('Transcript View', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Transcript is part of Recording view + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + }); + + test('should display transcript area', async ({ page }) => { + // Main content area should be visible + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); + + test('should show empty state when no transcript', async ({ page }) => { + // Either show "Listening..." or "No transcript yet" + const emptyMessage = page.getByText(/Listening|No transcript yet/); + + // May or may not be visible depending on state + const hasEmptyState = await emptyMessage.isVisible().catch(() => false); + const hasSegments = (await page.locator('[class*="rounded-lg p-3"]').count()) > 0; + + expect(hasEmptyState || hasSegments).toBe(true); + }); + + test('should show partial text indicator when recording', async ({ page }) => { + // Partial text appears when ASR is processing + // This would show a dashed border element + const partialIndicator = page.locator('[class*="border-dashed"]'); + // May or may not be visible depending on recording state + await page.waitForTimeout(100); + }); +}); + +test.describe('Transcript Segment Display', () => { + test('should display speaker labels on segments', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // If segments exist, they should have speaker labels + const segments = page.locator('[class*="rounded-lg p-3"]'); + + if ((await segments.count()) > 0) { + // Speaker badge should have color styling + const speakerBadge = segments.first().locator('[class*="rounded text-xs"]'); + await expect(speakerBadge).toBeVisible(); + } + }); + + test('should display timestamps on segments', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const segments = page.locator('[class*="rounded-lg p-3"]'); + + if ((await segments.count()) > 0) { + // Timestamps should be visible (format: MM:SS - MM:SS) + const timestamp = segments.first().getByText(/\d{1,2}:\d{2}/); + await expect(timestamp.first()).toBeVisible(); + } + }); +}); + +test.describe('Transcript Click to Seek', () => { + test('should have clickable segments', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + const segments = page.locator('[class*="cursor-pointer"]'); + + if ((await segments.count()) > 0) { + // Segments should be clickable (have cursor-pointer) + const firstSegment = segments.first(); + await expect(firstSegment).toHaveClass(/cursor-pointer/); + } + }); +}); + +test.describe('Transcript Annotations', () => { + test('should show annotation toolbar on segment hover', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // If there's a current meeting and segments + const segments = page.locator('[class*="rounded-lg p-3"]'); + + if ((await segments.count()) > 0) { + await segments.first().hover(); + // Annotation toolbar should appear on hover + await page.waitForTimeout(100); + const toolbar = page.locator('[class*="group-hover:block"]'); + // May be visible if meeting is loaded + } + }); +}); + +test.describe('Transcript Auto-scroll', () => { + test('should have scrollable transcript container', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Transcript container should be scrollable + const transcriptContainer = page.locator('[class*="overflow-auto"]'); + await expect(transcriptContainer.first()).toBeVisible(); + }); +}); + +test.describe('Transcript Accessibility', () => { + test('should have proper text contrast', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Text should be visible and readable + const mainContent = page.locator('main'); + await expect(mainContent).toBeVisible(); + }); + + test('should be keyboard navigable', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // Tab through transcript area + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + const focused = page.locator(':focus'); + await expect(focused).toBeVisible(); + }); +}); + +test.describe('Transcript and Playback Integration', () => { + test('should highlight segment during playback', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Recording', exact: true }).click(); + + // If segments exist and playback is active + const highlightedSegment = page.locator('[class*="bg-primary/20"]'); + + // May or may not be visible depending on playback state + await page.waitForTimeout(100); + }); +}); diff --git a/client/package-lock.json b/client/package-lock.json index c1923e1..fe67443 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -30,15 +30,22 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@biomejs/biome": "^2.3.10", + "@playwright/test": "^1.41.0", "@tauri-apps/cli": "^2.0.0", + "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^14.2.0", + "@testing-library/user-event": "^14.5.0", + "@types/node": "^25.0.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", + "@vitest/coverage-v8": "^1.2.0", "@vitest/ui": "^1.2.0", "autoprefixer": "^10.4.0", "jsdom": "^24.0.0", "postcss": "^8.4.0", + "prettier": "^3.7.4", "tailwindcss": "^3.4.0", "tailwindcss-animate": "^1.0.7", "typescript": "^5.3.0", @@ -46,6 +53,13 @@ "vitest": "^1.2.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -59,6 +73,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -372,6 +400,176 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@biomejs/biome": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.10.tgz", + "integrity": "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.10", + "@biomejs/cli-darwin-x64": "2.3.10", + "@biomejs/cli-linux-arm64": "2.3.10", + "@biomejs/cli-linux-arm64-musl": "2.3.10", + "@biomejs/cli-linux-x64": "2.3.10", + "@biomejs/cli-linux-x64-musl": "2.3.10", + "@biomejs/cli-win32-arm64": "2.3.10", + "@biomejs/cli-win32-x64": "2.3.10" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.10.tgz", + "integrity": "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.10.tgz", + "integrity": "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.10.tgz", + "integrity": "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.10.tgz", + "integrity": "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.10.tgz", + "integrity": "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.10.tgz", + "integrity": "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.10.tgz", + "integrity": "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.10.tgz", + "integrity": "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -916,6 +1114,16 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -1017,6 +1225,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2343,6 +2567,33 @@ "node": ">=14" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "14.3.1", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", @@ -2362,6 +2613,20 @@ "react-dom": "^18.0.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2421,6 +2686,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2470,6 +2745,34 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -2835,6 +3138,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -2858,6 +3168,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3134,6 +3455,13 @@ "node": ">= 6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -3163,6 +3491,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3671,6 +4006,13 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3787,6 +4129,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -3904,6 +4268,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3965,6 +4336,35 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4317,6 +4717,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -4498,6 +4952,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4575,6 +5070,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -4777,6 +5295,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -4822,6 +5350,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4915,6 +5453,53 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5088,6 +5673,22 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -5311,6 +5912,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -5720,6 +6335,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", @@ -5867,6 +6495,21 @@ "node": ">=10.13.0" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6061,6 +6704,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -6475,6 +7125,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/client/package.json b/client/package.json index 6d0132f..f61479a 100644 --- a/client/package.json +++ b/client/package.json @@ -8,7 +8,12 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint": "biome check src", + "lint:fix": "biome check src --write", + "lint:rs": "cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", + "format:rs": "cargo fmt --manifest-path src-tauri/Cargo.toml", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -18,45 +23,48 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-dialog": "^2.0.0", - "@tauri-apps/plugin-fs": "^2.0.0", - "@tauri-apps/plugin-notification": "^2.0.0", - "@tauri-apps/plugin-shell": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "zustand": "^4.5.0", - "immer": "^10.0.0", - "react-virtuoso": "^4.7.0", - "lucide-react": "^0.400.0", - "clsx": "^2.1.0", - "tailwind-merge": "^2.2.0", - "class-variance-authority": "^0.7.0", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", - "date-fns": "^3.3.0" + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-dialog": "^2.0.0", + "@tauri-apps/plugin-fs": "^2.0.0", + "@tauri-apps/plugin-notification": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "date-fns": "^3.3.0", + "immer": "^10.0.0", + "lucide-react": "^0.400.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-virtuoso": "^4.7.0", + "tailwind-merge": "^2.2.0", + "zustand": "^4.5.0" }, "devDependencies": { + "@biomejs/biome": "^2.3.10", + "@playwright/test": "^1.41.0", "@tauri-apps/cli": "^2.0.0", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^14.2.0", + "@testing-library/user-event": "^14.5.0", + "@types/node": "^25.0.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", + "@vitest/coverage-v8": "^1.2.0", + "@vitest/ui": "^1.2.0", "autoprefixer": "^10.4.0", + "jsdom": "^24.0.0", "postcss": "^8.4.0", + "prettier": "^3.7.4", "tailwindcss": "^3.4.0", + "tailwindcss-animate": "^1.0.7", "typescript": "^5.3.0", "vite": "^5.1.0", - "@vitest/ui": "^1.2.0", - "@vitest/coverage-v8": "^1.2.0", - "vitest": "^1.2.0", - "@testing-library/react": "^14.2.0", - "@testing-library/jest-dom": "^6.4.0", - "@testing-library/user-event": "^14.5.0", - "jsdom": "^24.0.0", - "tailwindcss-animate": "^1.0.7", - "@playwright/test": "^1.41.0" + "vitest": "^1.2.0" } } diff --git a/client/postcss.config.js b/client/postcss.config.js index 12a703d..2aa7205 100644 --- a/client/postcss.config.js +++ b/client/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 8246c9f..fcfbbf1 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -112,6 +112,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + [[package]] name = "anyhow" version = "1.0.100" @@ -1378,6 +1384,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1645,6 +1657,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "futf" version = "0.1.5" @@ -2249,12 +2267,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.10.1" @@ -2965,6 +2977,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "muda" version = "0.17.1" @@ -3108,6 +3147,7 @@ dependencies = [ "futures", "keyring", "log", + "mockall", "objc", "once_cell", "parking_lot", @@ -3128,9 +3168,11 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-shell", "tauri-plugin-window-state", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", + "tokio-test", "tonic", "tonic-build", "tracing", @@ -3956,6 +3998,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5250,7 +5318,6 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "http-range", "jni", "libc", "log", @@ -5612,6 +5679,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -5742,6 +5815,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.17" diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index 64970fc..d46a7fd 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ tonic-build = "0.12" [dependencies] # Tauri -tauri = { version = "2.0", features = ["protocol-asset"] } +tauri = { version = "2.0", features = [] } tauri-plugin-shell = "2.0" tauri-plugin-dialog = "2.0" tauri-plugin-fs = "2.0" diff --git a/client/src-tauri/build.rs b/client/src-tauri/build.rs index 3ab409f..b5b1416 100644 --- a/client/src-tauri/build.rs +++ b/client/src-tauri/build.rs @@ -12,7 +12,7 @@ fn main() -> Result<(), Box> { .build_server(false) // Client only .build_client(true) .out_dir("src/grpc") - .compile(&[proto_file], &[proto_include])?; + .compile_protos(&[proto_file], &[proto_include])?; } // Standard Tauri build diff --git a/client/src-tauri/icons/128x128.png b/client/src-tauri/icons/128x128.png new file mode 100644 index 0000000..f44ac8f Binary files /dev/null and b/client/src-tauri/icons/128x128.png differ diff --git a/client/src-tauri/icons/128x128@2x.png b/client/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..6639652 Binary files /dev/null and b/client/src-tauri/icons/128x128@2x.png differ diff --git a/client/src-tauri/icons/32x32.png b/client/src-tauri/icons/32x32.png new file mode 100644 index 0000000..b32ffba Binary files /dev/null and b/client/src-tauri/icons/32x32.png differ diff --git a/tests/client/__init__.py b/client/src-tauri/icons/icon.icns similarity index 100% rename from tests/client/__init__.py rename to client/src-tauri/icons/icon.icns diff --git a/client/src-tauri/icons/icon.ico b/client/src-tauri/icons/icon.ico new file mode 100644 index 0000000..e69de29 diff --git a/client/src-tauri/src/audio/capture.rs b/client/src-tauri/src/audio/capture.rs new file mode 100644 index 0000000..8ca4f50 --- /dev/null +++ b/client/src-tauri/src/audio/capture.rs @@ -0,0 +1,238 @@ +//! Audio capture using cpal. +//! +//! Provides real-time audio input capture with callback-based sample delivery. + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{SampleFormat, Stream, StreamConfig}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use crate::constants::audio::DEFAULT_SAMPLE_RATE; +use crate::helpers::now_timestamp; + +/// Audio capture handle. +/// +/// Wraps a cpal input stream and provides start/stop controls. +/// Audio samples are delivered via callback as f32 with timestamp. +pub struct AudioCapture { + stream: Option, + running: Arc, +} + +impl AudioCapture { + /// Create a new audio capture instance. + pub fn new() -> Result { + Ok(Self { + stream: None, + running: Arc::new(AtomicBool::new(false)), + }) + } + + /// Start capturing audio from the default input device. + /// + /// The callback receives audio samples as f32 slice and a timestamp (Unix seconds). + /// Samples are delivered in chunks as they become available from the audio device. + pub fn start(&mut self, callback: F) -> Result<(), String> + where + F: FnMut(&[f32], f64) + Send + 'static, + { + if self.running.load(Ordering::SeqCst) { + return Err("Capture already running".to_string()); + } + + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or("No input device available")?; + + let supported_config = device + .default_input_config() + .map_err(|e| format!("Failed to get input config: {e}"))?; + + let sample_format = supported_config.sample_format(); + let config: StreamConfig = supported_config.into(); + + // Log the device info + let device_name = device.name().unwrap_or_else(|_| "Unknown".to_string()); + log::info!( + "Starting audio capture: device={}, rate={}, channels={}, format={:?}", + device_name, + config.sample_rate.0, + config.channels, + sample_format + ); + + let running = Arc::clone(&self.running); + let error_running = Arc::clone(&self.running); + + // Build the stream based on sample format + let stream = match sample_format { + SampleFormat::F32 => self.build_stream(&device, &config, callback, running)?, + SampleFormat::I16 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::U16 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::I8 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::I32 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::I64 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::U8 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::U32 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::U64 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + SampleFormat::F64 => { + self.build_stream_converting::(&device, &config, callback, running)? + } + _ => return Err(format!("Unsupported sample format: {:?}", sample_format)), + }; + + // Start the stream + stream.play().map_err(|e| { + error_running.store(false, Ordering::SeqCst); + format!("Failed to start stream: {e}") + })?; + + self.running.store(true, Ordering::SeqCst); + self.stream = Some(stream); + + Ok(()) + } + + /// Build a stream for f32 samples (native, no conversion needed). + fn build_stream( + &self, + device: &cpal::Device, + config: &StreamConfig, + mut callback: F, + running: Arc, + ) -> Result + where + F: FnMut(&[f32], f64) + Send + 'static, + { + let err_running = Arc::clone(&running); + + device + .build_input_stream( + config, + move |data: &[f32], _: &cpal::InputCallbackInfo| { + if running.load(Ordering::SeqCst) { + let timestamp = now_timestamp(); + callback(data, timestamp); + } + }, + move |err| { + log::error!("Audio stream error: {}", err); + err_running.store(false, Ordering::SeqCst); + }, + None, + ) + .map_err(|e| format!("Failed to build input stream: {e}")) + } + + /// Build a stream with sample format conversion to f32. + fn build_stream_converting( + &self, + device: &cpal::Device, + config: &StreamConfig, + mut callback: F, + running: Arc, + ) -> Result + where + T: cpal::SizedSample + cpal::FromSample + Send + 'static, + f32: cpal::FromSample, + F: FnMut(&[f32], f64) + Send + 'static, + { + let err_running = Arc::clone(&running); + + device + .build_input_stream( + config, + move |data: &[T], _: &cpal::InputCallbackInfo| { + if running.load(Ordering::SeqCst) { + let timestamp = now_timestamp(); + // Convert samples to f32 + let converted: Vec = + data.iter().map(|&s| cpal::Sample::from_sample(s)).collect(); + callback(&converted, timestamp); + } + }, + move |err| { + log::error!("Audio stream error: {}", err); + err_running.store(false, Ordering::SeqCst); + }, + None, + ) + .map_err(|e| format!("Failed to build input stream: {e}")) + } + + /// Stop capturing audio. + pub fn stop(&mut self) { + self.running.store(false, Ordering::SeqCst); + // Dropping the stream will stop it + self.stream = None; + log::info!("Audio capture stopped"); + } + + /// Check if capture is running. + pub fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + /// Get the default sample rate. + /// + /// Returns the configured default or queries the default device. + pub fn default_sample_rate() -> u32 { + cpal::default_host() + .default_input_device() + .and_then(|d| d.default_input_config().ok()) + .map(|c| c.sample_rate().0) + .unwrap_or(DEFAULT_SAMPLE_RATE) + } +} + +impl Default for AudioCapture { + fn default() -> Self { + Self::new().expect("Failed to create AudioCapture") + } +} + +// Stream is not Send, but our wrapper is safe because we only access +// the stream from the thread that created it +unsafe impl Send for AudioCapture {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_create_capture() { + let capture = AudioCapture::new(); + assert!(capture.is_ok()); + } + + #[test] + fn default_sample_rate_is_reasonable() { + let rate = AudioCapture::default_sample_rate(); + // Sample rate should be between 8kHz and 192kHz + assert!(rate >= 8000); + assert!(rate <= 192000); + } + + #[test] + fn capture_starts_not_running() { + let capture = AudioCapture::new().unwrap(); + assert!(!capture.is_running()); + } +} diff --git a/client/src-tauri/src/audio/devices.rs b/client/src-tauri/src/audio/devices.rs new file mode 100644 index 0000000..a3d141f --- /dev/null +++ b/client/src-tauri/src/audio/devices.rs @@ -0,0 +1,76 @@ +//! Audio device enumeration using cpal. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use crate::grpc::types::AudioDeviceInfo; +use cpal::traits::{DeviceTrait, HostTrait}; + +/// Generate a stable device ID from device name. +/// +/// Uses hash of name to provide consistent ID across restarts, +/// unlike enumeration index which changes with device ordering. +fn stable_device_id(name: &str) -> u32 { + let mut hasher = DefaultHasher::new(); + name.hash(&mut hasher); + (hasher.finish() % u32::MAX as u64) as u32 +} + +/// List available audio input devices. +pub fn list_input_devices() -> Result, String> { + let host = cpal::default_host(); + let default_name = host + .default_input_device() + .and_then(|d| d.name().ok()) + .unwrap_or_default(); + + let mut devices = Vec::new(); + + for device in host + .input_devices() + .map_err(|e| format!("Failed to enumerate devices: {e}"))? + { + let name = match device.name() { + Ok(n) => n, + Err(_) => continue, + }; + let config = device + .default_input_config() + .map_err(|e| format!("Failed to get config: {e}"))?; + + devices.push(AudioDeviceInfo { + id: stable_device_id(&name), + name: name.clone(), + channels: config.channels() as u32, + sample_rate: config.sample_rate().0, + is_default: name == default_name, + }); + } + + Ok(devices) +} + +/// Get the default input device. +pub fn get_default_input_device() -> Result, String> { + let host = cpal::default_host(); + + let device = match host.default_input_device() { + Some(d) => d, + None => return Ok(None), + }; + + let name = device + .name() + .map_err(|e| format!("Failed to get name: {e}"))?; + let config = device + .default_input_config() + .map_err(|e| format!("Failed to get config: {e}"))?; + + Ok(Some(AudioDeviceInfo { + id: stable_device_id(&name), + name, + channels: config.channels() as u32, + sample_rate: config.sample_rate().0, + is_default: true, + })) +} diff --git a/client/src-tauri/src/audio/loader.rs b/client/src-tauri/src/audio/loader.rs new file mode 100644 index 0000000..0258272 --- /dev/null +++ b/client/src-tauri/src/audio/loader.rs @@ -0,0 +1,126 @@ +//! Audio file loading and decryption. +//! +//! Decrypts and parses .nfaudio files into playable audio buffers. + +use std::path::Path; + +use crate::crypto::CryptoBox; +use crate::grpc::types::TimestampedAudio; + +/// Audio file format constants +const SAMPLE_RATE_BYTES: usize = 4; +const NUM_SAMPLES_BYTES: usize = 4; +const HEADER_SIZE: usize = SAMPLE_RATE_BYTES + NUM_SAMPLES_BYTES; +const BYTES_PER_SAMPLE: usize = 4; + +/// Default chunk duration in seconds for TimestampedAudio segments +const CHUNK_DURATION: f64 = 0.1; + +/// Load and decrypt a .nfaudio file into playable audio buffer. +/// +/// File format (after decryption): +/// - [4 bytes: sample_rate u32 LE] +/// - [4 bytes: num_samples u32 LE] +/// - [num_samples * 4 bytes: f32 samples LE] +pub fn load_audio_file( + crypto: &CryptoBox, + path: &Path, +) -> Result<(Vec, u32), String> { + // Read encrypted file + let encrypted = std::fs::read(path).map_err(|e| format!("Failed to read audio file: {}", e))?; + + // Decrypt + let decrypted = crypto.decrypt(&encrypted)?; + + // Parse header + if decrypted.len() < HEADER_SIZE { + return Err("Audio file too short".to_string()); + } + + let sample_rate = u32::from_le_bytes( + decrypted[..SAMPLE_RATE_BYTES] + .try_into() + .map_err(|_| "Invalid sample rate bytes")?, + ); + if sample_rate == 0 { + return Err("Invalid sample rate: 0".to_string()); + } + + let num_samples = u32::from_le_bytes( + decrypted[SAMPLE_RATE_BYTES..HEADER_SIZE] + .try_into() + .map_err(|_| "Invalid sample count bytes")?, + ) as usize; + + // Validate payload size + let expected_size = HEADER_SIZE + num_samples * BYTES_PER_SAMPLE; + if decrypted.len() < expected_size { + return Err(format!( + "Audio file truncated: expected {} bytes, got {}", + expected_size, + decrypted.len() + )); + } + + // Parse samples + let samples = parse_samples(&decrypted[HEADER_SIZE..], num_samples)?; + + // Convert to TimestampedAudio chunks + let buffer = samples_to_chunks(&samples, sample_rate); + + Ok((buffer, sample_rate)) +} + +/// Parse f32 samples from raw bytes. +fn parse_samples(data: &[u8], num_samples: usize) -> Result, String> { + let mut samples = Vec::with_capacity(num_samples); + + for i in 0..num_samples { + let offset = i * BYTES_PER_SAMPLE; + let bytes: [u8; 4] = data[offset..offset + BYTES_PER_SAMPLE] + .try_into() + .map_err(|_| "Invalid sample bytes")?; + samples.push(f32::from_le_bytes(bytes)); + } + + Ok(samples) +} + +/// Convert flat samples into TimestampedAudio chunks. +fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec { + let chunk_samples = ((sample_rate as f64 * CHUNK_DURATION) as usize).max(1); + let mut chunks = Vec::new(); + let mut offset = 0; + + while offset < samples.len() { + let end = (offset + chunk_samples).min(samples.len()); + let frame_count = end - offset; + let duration = frame_count as f64 / sample_rate as f64; + let timestamp = offset as f64 / sample_rate as f64; + + chunks.push(TimestampedAudio { + frames: samples[offset..end].to_vec(), + timestamp, + duration, + }); + + offset = end; + } + + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_samples_to_chunks() { + let samples: Vec = (0..4800).map(|i| i as f32 / 4800.0).collect(); + let chunks = samples_to_chunks(&samples, 48000); + + assert!(!chunks.is_empty()); + assert!((chunks[0].duration - 0.1).abs() < 0.01); + assert_eq!(chunks[0].timestamp, 0.0); + } +} diff --git a/client/src-tauri/src/audio/mod.rs b/client/src-tauri/src/audio/mod.rs index b72dcd9..001c166 100644 --- a/client/src-tauri/src/audio/mod.rs +++ b/client/src-tauri/src/audio/mod.rs @@ -1,202 +1,17 @@ -//! Audio capture and playback functionality +//! Audio capture and playback functionality. //! -//! This module provides audio input/output using cpal and rodio. +//! Organized into submodules: +//! - `capture`: Audio input using cpal +//! - `playback`: Audio output using rodio +//! - `devices`: Device enumeration +//! - `loader`: Encrypted audio file loading -use crate::grpc::types::AudioDeviceInfo; +mod capture; +mod devices; +mod loader; +mod playback; -/// List available audio input devices -pub fn list_input_devices() -> Result, String> { - use cpal::traits::{DeviceTrait, HostTrait}; - - let host = cpal::default_host(); - let default_device = host.default_input_device(); - let default_name = default_device - .as_ref() - .and_then(|d| d.name().ok()) - .unwrap_or_default(); - - let mut devices = Vec::new(); - let mut id = 0u32; - - for device in host - .input_devices() - .map_err(|e| format!("Failed to enumerate devices: {}", e))? - { - if let Ok(name) = device.name() { - let config = device - .default_input_config() - .map_err(|e| format!("Failed to get config: {}", e))?; - - devices.push(AudioDeviceInfo { - id, - name: name.clone(), - channels: config.channels() as u32, - sample_rate: config.sample_rate().0, - is_default: name == default_name, - }); - id += 1; - } - } - - Ok(devices) -} - -/// Get the default input device -pub fn get_default_input_device() -> Result, String> { - use cpal::traits::{DeviceTrait, HostTrait}; - - let host = cpal::default_host(); - let device = match host.default_input_device() { - Some(d) => d, - None => return Ok(None), - }; - - let name = device.name().map_err(|e| format!("Failed to get name: {}", e))?; - let config = device - .default_input_config() - .map_err(|e| format!("Failed to get config: {}", e))?; - - Ok(Some(AudioDeviceInfo { - id: 0, - name, - channels: config.channels() as u32, - sample_rate: config.sample_rate().0, - is_default: true, - })) -} - -/// Audio capture handle -pub struct AudioCapture { - #[allow(dead_code)] - stream: Option, - running: std::sync::atomic::AtomicBool, -} - -impl AudioCapture { - /// Create a new audio capture instance - pub fn new() -> Result { - Ok(Self { - stream: None, - running: std::sync::atomic::AtomicBool::new(false), - }) - } - - /// Start capturing audio - pub fn start(&mut self, mut _callback: F) -> Result<(), String> - where - F: FnMut(&[f32], f64) + Send + 'static, - { - // TODO: Implement actual audio capture with cpal - self.running - .store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - } - - /// Stop capturing audio - pub fn stop(&mut self) { - self.running - .store(false, std::sync::atomic::Ordering::SeqCst); - self.stream = None; - } - - /// Check if capture is running - pub fn is_running(&self) -> bool { - self.running.load(std::sync::atomic::Ordering::SeqCst) - } -} - -/// Audio playback handle -pub struct AudioPlayback { - #[allow(dead_code)] - sink: Option, - #[allow(dead_code)] - stream: Option, - #[allow(dead_code)] - stream_handle: Option, - position: std::sync::atomic::AtomicU64, - duration: f64, - playing: std::sync::atomic::AtomicBool, -} - -impl AudioPlayback { - /// Create a new audio playback instance - pub fn new() -> Result { - Ok(Self { - sink: None, - stream: None, - stream_handle: None, - position: std::sync::atomic::AtomicU64::new(0), - duration: 0.0, - playing: std::sync::atomic::AtomicBool::new(false), - }) - } - - /// Start playback with position callback - pub fn play( - &mut self, - _audio_buffer: Vec, - mut _on_position: F, - ) -> Result<(), String> - where - F: FnMut(f64) + Send + 'static, - { - // TODO: Implement actual audio playback with rodio - self.playing - .store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - } - - /// Pause playback - pub fn pause(&mut self) -> Result<(), String> { - if let Some(ref sink) = self.sink { - sink.pause(); - } - self.playing - .store(false, std::sync::atomic::Ordering::SeqCst); - Ok(()) - } - - /// Resume playback - pub fn resume(&mut self) -> Result<(), String> { - if let Some(ref sink) = self.sink { - sink.play(); - } - self.playing - .store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - } - - /// Stop playback - pub fn stop(&mut self) { - self.sink = None; - self.position - .store(0, std::sync::atomic::Ordering::SeqCst); - self.playing - .store(false, std::sync::atomic::Ordering::SeqCst); - } - - /// Seek to position - pub fn seek(&mut self, position: f64) -> Result<(), String> { - let pos_bits = position.to_bits(); - self.position - .store(pos_bits, std::sync::atomic::Ordering::SeqCst); - // TODO: Implement actual seeking - Ok(()) - } - - /// Get current position - pub fn position(&self) -> f64 { - let bits = self.position.load(std::sync::atomic::Ordering::SeqCst); - f64::from_bits(bits) - } - - /// Get duration - pub fn duration(&self) -> f64 { - self.duration - } - - /// Check if playing - pub fn is_playing(&self) -> bool { - self.playing.load(std::sync::atomic::Ordering::SeqCst) - } -} +pub use capture::AudioCapture; +pub use devices::{get_default_input_device, list_input_devices}; +pub use loader::load_audio_file; +pub use playback::{PlaybackHandle, PlaybackStarted}; diff --git a/client/src-tauri/src/audio/playback.rs b/client/src-tauri/src/audio/playback.rs new file mode 100644 index 0000000..e882be8 --- /dev/null +++ b/client/src-tauri/src/audio/playback.rs @@ -0,0 +1,209 @@ +//! Audio playback using rodio with thread-safe channel-based control. +//! +//! The audio stream is owned by a dedicated thread since `cpal::Stream` is not `Send`. +//! Commands are sent via channels, making `PlaybackHandle` safe to store in `AppState`. + +use crate::grpc::types::TimestampedAudio; +use parking_lot::Mutex; +use rodio::{buffer::SamplesBuffer, OutputStream, Sink}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +/// Commands sent to the audio thread. +#[derive(Debug)] +pub enum PlaybackCommand { + /// Start playback with audio buffer and sample rate. + Play(Vec, u32), + /// Pause playback. + Pause, + /// Resume playback. + Resume, + /// Stop playback and reset. + Stop, + /// Shutdown the audio thread. + Shutdown, +} + +/// Response from audio thread after Play command. +#[derive(Debug)] +pub struct PlaybackStarted { + pub duration: f64, + pub playing_flag: Arc, + pub position_atomic: Arc, +} + +/// Thread-safe handle for controlling audio playback. +/// This can be stored in `AppState` as it only contains `Send + Sync` types. +pub struct PlaybackHandle { + command_tx: Sender, + response_rx: Mutex>>, + _thread: JoinHandle<()>, +} + +impl PlaybackHandle { + /// Create a new playback handle, spawning the audio thread. + pub fn new() -> Result { + let (command_tx, command_rx) = mpsc::channel::(); + let (response_tx, response_rx) = mpsc::channel::>(); + + let thread = thread::spawn(move || { + audio_thread_main(command_rx, response_tx); + }); + + Ok(Self { + command_tx, + response_rx: Mutex::new(response_rx), + _thread: thread, + }) + } + + /// Start playback with the given audio buffer. + pub fn play( + &self, + audio_buffer: Vec, + sample_rate: u32, + ) -> Result { + self.command_tx + .send(PlaybackCommand::Play(audio_buffer, sample_rate)) + .map_err(|_| "Audio thread disconnected".to_string())?; + + self.response_rx + .lock() + .recv() + .map_err(|_| "Audio thread disconnected".to_string())? + } + + /// Pause playback. + pub fn pause(&self) -> Result<(), String> { + self.command_tx + .send(PlaybackCommand::Pause) + .map_err(|_| "Audio thread disconnected".to_string()) + } + + /// Resume playback. + pub fn resume(&self) -> Result<(), String> { + self.command_tx + .send(PlaybackCommand::Resume) + .map_err(|_| "Audio thread disconnected".to_string()) + } + + /// Stop playback. + pub fn stop(&self) -> Result<(), String> { + self.command_tx + .send(PlaybackCommand::Stop) + .map_err(|_| "Audio thread disconnected".to_string()) + } +} + +impl Drop for PlaybackHandle { + fn drop(&mut self) { + let _ = self.command_tx.send(PlaybackCommand::Shutdown); + } +} + +// ============================================================================ +// Audio Thread +// ============================================================================ + +/// Main loop for the audio thread. +fn audio_thread_main( + command_rx: Receiver, + response_tx: Sender>, +) { + // Audio state owned by this thread (not Send, stays here) + let mut audio_state: Option = None; + + loop { + let command = match command_rx.recv() { + Ok(cmd) => cmd, + Err(_) => break, // Channel closed + }; + + match command { + PlaybackCommand::Play(audio_buffer, sample_rate) => { + let result = start_playback(&mut audio_state, audio_buffer, sample_rate); + let _ = response_tx.send(result); + } + PlaybackCommand::Pause => { + if let Some(ref state) = audio_state { + state.sink.pause(); + state.playing.store(false, Ordering::SeqCst); + } + } + PlaybackCommand::Resume => { + if let Some(ref state) = audio_state { + state.sink.play(); + state.playing.store(true, Ordering::SeqCst); + } + } + PlaybackCommand::Stop => { + if let Some(ref state) = audio_state { + state.sink.stop(); + state.playing.store(false, Ordering::SeqCst); + state.position.store(0, Ordering::SeqCst); + } + audio_state = None; + } + PlaybackCommand::Shutdown => break, + } + } +} + +/// Internal audio state owned by the audio thread. +struct AudioState { + _stream: OutputStream, + sink: Sink, + position: Arc, + playing: Arc, +} + +fn start_playback( + audio_state: &mut Option, + audio_buffer: Vec, + sample_rate: u32, +) -> Result { + if audio_buffer.is_empty() { + return Err("No audio to play".to_string()); + } + + let sample_rate = sample_rate.max(1); + + // Create output stream + let (stream, stream_handle) = + OutputStream::try_default().map_err(|e| format!("Failed to get output stream: {e}"))?; + + let sink = + Sink::try_new(&stream_handle).map_err(|e| format!("Failed to create sink: {e}"))?; + + // Concatenate all frames into single buffer + let samples: Vec = audio_buffer + .iter() + .flat_map(|ta| ta.frames.iter().copied()) + .collect(); + + let duration = samples.len() as f64 / sample_rate as f64; + + let source = SamplesBuffer::new(1, sample_rate, samples); + sink.append(source); + sink.play(); + + let position = Arc::new(AtomicU64::new(0)); + let playing = Arc::new(AtomicBool::new(true)); + + let started = PlaybackStarted { + duration, + playing_flag: Arc::clone(&playing), + position_atomic: Arc::clone(&position), + }; + + *audio_state = Some(AudioState { + _stream: stream, + sink, + position, + playing, + }); + + Ok(started) +} diff --git a/client/src-tauri/src/cache/memory.rs b/client/src-tauri/src/cache/memory.rs index a1d6b0d..d27b0b8 100644 --- a/client/src-tauri/src/cache/memory.rs +++ b/client/src-tauri/src/cache/memory.rs @@ -148,7 +148,9 @@ impl Cache for MemoryCache { // Serialize synchronously since we have a reference let data = match serde_json::to_string(value) { Ok(d) => d, - Err(e) => return Box::pin(async move { Err(CacheError::Serialization(e.to_string())) }), + Err(e) => { + return Box::pin(async move { Err(CacheError::Serialization(e.to_string())) }) + } }; Box::pin(async move { @@ -252,7 +254,10 @@ mod tests { async fn set_and_get() { let cache = MemoryCache::new(100, Duration::from_secs(60)); - cache.set("key1", &"value1".to_string(), None).await.unwrap(); + cache + .set("key1", &"value1".to_string(), None) + .await + .unwrap(); let result: Option = cache.get("key1").await.unwrap(); assert_eq!(result, Some("value1".to_string())); @@ -270,7 +275,10 @@ mod tests { async fn expired_entries_are_removed() { let cache = MemoryCache::new(100, Duration::from_millis(10)); - cache.set("key1", &"value1".to_string(), None).await.unwrap(); + cache + .set("key1", &"value1".to_string(), None) + .await + .unwrap(); // Wait for expiration tokio::time::sleep(Duration::from_millis(20)).await; @@ -283,7 +291,10 @@ mod tests { async fn delete_removes_entry() { let cache = MemoryCache::new(100, Duration::from_secs(60)); - cache.set("key1", &"value1".to_string(), None).await.unwrap(); + cache + .set("key1", &"value1".to_string(), None) + .await + .unwrap(); let deleted = cache.delete("key1").await.unwrap(); assert!(deleted); @@ -297,7 +308,10 @@ mod tests { assert!(!cache.exists("key1").await.unwrap()); - cache.set("key1", &"value1".to_string(), None).await.unwrap(); + cache + .set("key1", &"value1".to_string(), None) + .await + .unwrap(); assert!(cache.exists("key1").await.unwrap()); } @@ -305,14 +319,23 @@ mod tests { async fn lru_eviction() { let cache = MemoryCache::new(2, Duration::from_secs(60)); - cache.set("key1", &"value1".to_string(), None).await.unwrap(); - cache.set("key2", &"value2".to_string(), None).await.unwrap(); + cache + .set("key1", &"value1".to_string(), None) + .await + .unwrap(); + cache + .set("key2", &"value2".to_string(), None) + .await + .unwrap(); // Access key1 to make it more recent let _: Option = cache.get("key1").await.unwrap(); // Add key3, should evict key2 (least recently used) - cache.set("key3", &"value3".to_string(), None).await.unwrap(); + cache + .set("key3", &"value3".to_string(), None) + .await + .unwrap(); assert!(cache.exists("key1").await.unwrap()); assert!(!cache.exists("key2").await.unwrap()); @@ -323,7 +346,10 @@ mod tests { async fn stats_tracking() { let cache = MemoryCache::new(100, Duration::from_secs(60)); - cache.set("key1", &"value1".to_string(), None).await.unwrap(); + cache + .set("key1", &"value1".to_string(), None) + .await + .unwrap(); let _: Option = cache.get("key1").await.unwrap(); // hit let _: Option = cache.get("key2").await.unwrap(); // miss diff --git a/client/src-tauri/src/cache/mod.rs b/client/src-tauri/src/cache/mod.rs index ddeb402..bace97e 100644 --- a/client/src-tauri/src/cache/mod.rs +++ b/client/src-tauri/src/cache/mod.rs @@ -14,7 +14,7 @@ use std::time::Duration; use serde::{de::DeserializeOwned, Serialize}; -use crate::config::{config, CacheBackend}; +use crate::config::config; pub use memory::MemoryCache; @@ -139,40 +139,13 @@ impl Cache for NoOpCache { } } -/// Create a cache instance based on configuration -pub fn create_cache() -> Arc { +/// Create a memory cache instance based on configuration +pub fn create_cache() -> Arc { let cfg = config(); - - match cfg.cache.backend { - CacheBackend::Memory => Arc::new(MemoryCache::new( - cfg.cache.max_memory_items, - Duration::from_secs(cfg.cache.default_ttl_secs), - )), - CacheBackend::Redis => { - // Redis support requires the redis feature - #[cfg(feature = "redis")] - { - if let Some(url) = &cfg.cache.redis_url { - match redis::RedisCache::new(url) { - Ok(cache) => return Arc::new(cache), - Err(e) => { - tracing::warn!("Failed to connect to Redis, falling back to memory: {}", e); - } - } - } - } - #[cfg(not(feature = "redis"))] - { - tracing::warn!("Redis cache requested but 'redis' feature not enabled, using memory cache"); - } - // Fallback to memory cache - Arc::new(MemoryCache::new( - cfg.cache.max_memory_items, - Duration::from_secs(cfg.cache.default_ttl_secs), - )) - } - CacheBackend::None => Arc::new(NoOpCache), - } + Arc::new(MemoryCache::new( + cfg.cache.max_memory_items, + Duration::from_secs(cfg.cache.default_ttl_secs), + )) } /// Cache key builder for consistent key formatting diff --git a/client/src-tauri/src/commands/annotation.rs b/client/src-tauri/src/commands/annotation.rs index 7cb2e9d..fd4e18d 100644 --- a/client/src-tauri/src/commands/annotation.rs +++ b/client/src-tauri/src/commands/annotation.rs @@ -21,8 +21,16 @@ pub async fn add_annotation( let annotation = state .grpc_client - .add_annotation(&meeting_id, anno_type, &text, start_time, end_time, segment_ids) - .await?; + .add_annotation( + &meeting_id, + anno_type, + &text, + start_time, + end_time, + segment_ids, + ) + .await + .map_err(|e| e.to_string())?; // Add to cache state.annotations.write().push(annotation.clone()); @@ -36,7 +44,11 @@ pub async fn get_annotation( state: State<'_, Arc>, annotation_id: String, ) -> Result { - state.grpc_client.get_annotation(&annotation_id).await + state + .grpc_client + .get_annotation(&annotation_id) + .await + .map_err(|e| e.to_string()) } /// List annotations for a meeting @@ -54,7 +66,8 @@ pub async fn list_annotations( start_time.unwrap_or(0.0), end_time.unwrap_or(f64::MAX), ) - .await?; + .await + .map_err(|e| e.to_string())?; // Update cache *state.annotations.write() = annotations.clone(); @@ -85,7 +98,8 @@ pub async fn update_annotation( end_time, segment_ids, ) - .await?; + .await + .map_err(|e| e.to_string())?; // Update cache if let Some(pos) = state @@ -106,13 +120,14 @@ pub async fn delete_annotation( state: State<'_, Arc>, annotation_id: String, ) -> Result { - let success = state.grpc_client.delete_annotation(&annotation_id).await?; + let success = state + .grpc_client + .delete_annotation(&annotation_id) + .await + .map_err(|e| e.to_string())?; if success { - state - .annotations - .write() - .retain(|a| a.id != annotation_id); + state.annotations.write().retain(|a| a.id != annotation_id); } Ok(success) diff --git a/client/src-tauri/src/commands/audio.rs b/client/src-tauri/src/commands/audio.rs index 3347b37..7dcaae1 100644 --- a/client/src-tauri/src/commands/audio.rs +++ b/client/src-tauri/src/commands/audio.rs @@ -19,11 +19,21 @@ pub async fn select_audio_device( state: State<'_, Arc>, device_id: Option, ) -> Result<(), String> { - // Store preference - state.preferences.write().insert( - "audio_device_id".to_string(), - serde_json::json!(device_id), - ); + let mut prefs = state.preferences.write(); + + if let Some(id) = device_id { + let devices = audio::list_input_devices()?; + let device_name = devices.iter().find(|d| d.id == id).map(|d| d.name.clone()); + + prefs.insert("audio_device_id".to_string(), serde_json::json!(id)); + prefs.insert( + "audio_device_name".to_string(), + serde_json::json!(device_name), + ); + } else { + prefs.remove("audio_device_id"); + prefs.remove("audio_device_name"); + } Ok(()) } @@ -32,17 +42,31 @@ pub async fn select_audio_device( pub async fn get_current_device( state: State<'_, Arc>, ) -> Result, String> { - let device_id = state - .preferences - .read() + let prefs = state.preferences.read(); + let device_id = prefs .get("audio_device_id") .and_then(|v| v.as_u64()) .map(|id| id as u32); + let device_name = prefs + .get("audio_device_name") + .and_then(|v| v.as_str()) + .map(|name| name.to_string()); + + if device_id.is_none() && device_name.is_none() { + return audio::get_default_input_device(); + } + + let devices = audio::list_input_devices()?; + + if let Some(name) = device_name { + if let Some(device) = devices.iter().find(|d| d.name == name) { + return Ok(Some(device.clone())); + } + } if let Some(id) = device_id { - let devices = audio::list_input_devices()?; - Ok(devices.into_iter().find(|d| d.id == id)) - } else { - audio::get_default_input_device() + return Ok(devices.into_iter().find(|d| d.id == id)); } + + audio::get_default_input_device() } diff --git a/client/src-tauri/src/commands/diarization.rs b/client/src-tauri/src/commands/diarization.rs index 8832f54..552ccd9 100644 --- a/client/src-tauri/src/commands/diarization.rs +++ b/client/src-tauri/src/commands/diarization.rs @@ -17,6 +17,7 @@ pub async fn refine_speaker_diarization( .grpc_client .refine_speaker_diarization(&meeting_id, num_speakers) .await + .map_err(|e| e.to_string()) } /// Get status of a diarization job @@ -25,7 +26,11 @@ pub async fn get_diarization_job_status( state: State<'_, Arc>, job_id: String, ) -> Result { - state.grpc_client.get_diarization_job_status(&job_id).await + state + .grpc_client + .get_diarization_job_status(&job_id) + .await + .map_err(|e| e.to_string()) } /// Rename a speaker across all segments @@ -39,7 +44,8 @@ pub async fn rename_speaker( let result = state .grpc_client .rename_speaker(&meeting_id, &old_speaker_id, &new_speaker_name) - .await?; + .await + .map_err(|e| e.to_string())?; // Update cached segments if result.success { diff --git a/client/src-tauri/src/commands/export.rs b/client/src-tauri/src/commands/export.rs index 1685e8e..20bbf7d 100644 --- a/client/src-tauri/src/commands/export.rs +++ b/client/src-tauri/src/commands/export.rs @@ -15,7 +15,11 @@ pub async fn export_transcript( format: String, ) -> Result { let fmt = ExportFormat::from(format.as_str()); - state.grpc_client.export_transcript(&meeting_id, fmt).await + state + .grpc_client + .export_transcript(&meeting_id, fmt) + .await + .map_err(|e| e.to_string()) } /// Save export file to disk @@ -39,7 +43,7 @@ pub async fn save_export_file( .blocking_save_file(); if let Some(path) = file_path { - std::fs::write(path.path(), content) + std::fs::write(path.as_path().unwrap(), content) .map_err(|e| format!("Failed to write file: {}", e))?; Ok(true) } else { diff --git a/client/src-tauri/src/commands/meeting.rs b/client/src-tauri/src/commands/meeting.rs index fe17c9c..280dd4e 100644 --- a/client/src-tauri/src/commands/meeting.rs +++ b/client/src-tauri/src/commands/meeting.rs @@ -1,8 +1,11 @@ //! Meeting-related Tauri commands use std::sync::Arc; -use tauri::State; +use tauri::{AppHandle, Emitter, State}; +use crate::audio::load_audio_file; +use crate::constants::audio::DEFAULT_SAMPLE_RATE; +use crate::constants::Events; use crate::grpc::types::{MeetingDetails, MeetingInfo, MeetingState}; use crate::state::AppState; @@ -57,6 +60,7 @@ pub async fn get_meeting( .grpc_client .get_meeting(&meeting_id, include_segments, include_summary) .await + .map_err(|e| e.to_string()) } /// Delete a meeting @@ -72,13 +76,7 @@ pub async fn delete_meeting( state.meetings.write().retain(|m| m.id != meeting_id); // Clear if selected - if state - .selected_meeting - .read() - .as_ref() - .map(|m| &m.id) - == Some(&meeting_id) - { + if state.selected_meeting.read().as_ref().map(|m| &m.id) == Some(&meeting_id) { *state.selected_meeting.write() = None; } } @@ -89,25 +87,42 @@ pub async fn delete_meeting( /// Select a meeting for review (loads segments, annotations, audio) #[tauri::command] pub async fn select_meeting( + app: AppHandle, state: State<'_, Arc>, meeting_id: String, ) -> Result { // Stop any current playback - if *state.playback_state.read() != crate::state::PlaybackState::Stopped { - *state.playback_state.write() = crate::state::PlaybackState::Stopped; + { + let playback_guard = state.audio_playback.read(); + if let Some(ref handle) = *playback_guard { + let _ = handle.stop(); + } } + *state.audio_playback.write() = None; + *state.playback_state.write() = crate::state::PlaybackState::Stopped; + *state.playback_position.write() = 0.0; + *state.playback_samples_played.write() = 0; + *state.highlighted_segment_index.write() = None; + let _ = app.emit(Events::PLAYBACK_STATE, "stopped"); + let _ = app.emit(Events::PLAYBACK_POSITION, 0.0); + let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null); // Fetch meeting with segments let details = state .grpc_client .get_meeting(&meeting_id, true, true) - .await?; + .await + .map_err(|e| e.to_string())?; // Fetch annotations let annotations = state .grpc_client .list_annotations(&meeting_id, 0.0, 0.0) - .await?; + .await + .map_err(|e| e.to_string())?; + + // Load audio file if exists + let audio_result = load_meeting_audio(&state, &meeting_id); // Update state *state.selected_meeting.write() = Some(details.meeting.clone()); @@ -120,5 +135,37 @@ pub async fn select_meeting( *state.current_summary.write() = details.summary.clone(); *state.highlighted_segment_index.write() = None; + // Apply audio state + if let Ok((buffer, duration, sample_rate)) = audio_result { + *state.session_audio_buffer.write() = buffer; + *state.playback_duration.write() = duration; + *state.playback_position.write() = 0.0; + *state.playback_sample_rate.write() = sample_rate; + } else { + // Clear audio buffer if no audio file + state.session_audio_buffer.write().clear(); + *state.playback_duration.write() = 0.0; + *state.playback_position.write() = 0.0; + *state.playback_sample_rate.write() = DEFAULT_SAMPLE_RATE; + } + Ok(details) } + +/// Load audio file for a meeting from the meetings directory. +fn load_meeting_audio( + state: &Arc, + meeting_id: &str, +) -> Result<(Vec, f64, u32), String> { + let meetings_dir = state.meetings_dir.read().clone(); + let audio_path = meetings_dir.join(format!("{}.nfaudio", meeting_id)); + + if !audio_path.exists() { + return Err("Audio file not found".to_string()); + } + + let (buffer, sample_rate) = load_audio_file(&state.crypto, &audio_path)?; + let duration: f64 = buffer.iter().map(|ta| ta.duration).sum(); + + Ok((buffer, duration, sample_rate)) +} diff --git a/client/src-tauri/src/commands/mod.rs b/client/src-tauri/src/commands/mod.rs index 078e92e..364e251 100644 --- a/client/src-tauri/src/commands/mod.rs +++ b/client/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod diarization; pub mod export; pub mod meeting; pub mod playback; +pub mod preferences; pub mod recording; pub mod summary; pub mod triggers; diff --git a/client/src-tauri/src/commands/playback.rs b/client/src-tauri/src/commands/playback.rs index cb801b9..4fb4880 100644 --- a/client/src-tauri/src/commands/playback.rs +++ b/client/src-tauri/src/commands/playback.rs @@ -1,80 +1,99 @@ //! Playback-related Tauri commands +use std::sync::atomic::Ordering; use std::sync::Arc; -use tauri::State; +use std::time::Duration; +use tauri::{AppHandle, Emitter, State}; + +use crate::audio::PlaybackHandle; +use crate::constants::Events; use crate::state::{AppState, PlaybackInfo, PlaybackState}; -/// Start or resume playback +/// Position update interval in milliseconds. +const POSITION_UPDATE_INTERVAL_MS: u64 = 50; + +/// Start or resume playback. #[tauri::command] -pub async fn play(state: State<'_, Arc>) -> Result<(), String> { +pub async fn play(app: AppHandle, state: State<'_, Arc>) -> Result<(), String> { let current_state = *state.playback_state.read(); match current_state { PlaybackState::Stopped => { - // Start new playback - let audio_buffer = state.session_audio_buffer.read().clone(); - if audio_buffer.is_empty() { - return Err("No audio to play".to_string()); - } - - // TODO: Implement actual audio playback - *state.playback_state.write() = PlaybackState::Playing; + start_playback(&app, &state)?; } PlaybackState::Paused => { - // Resume - *state.playback_state.write() = PlaybackState::Playing; + resume_playback(&app, &state)?; } PlaybackState::Playing => { // Already playing } } + let _ = app.emit(Events::PLAYBACK_STATE, "playing"); Ok(()) } -/// Pause playback +/// Pause playback. #[tauri::command] -pub async fn pause(state: State<'_, Arc>) -> Result<(), String> { +pub async fn pause(app: AppHandle, state: State<'_, Arc>) -> Result<(), String> { + if let Some(ref handle) = *state.audio_playback.read() { + handle.pause()?; + } *state.playback_state.write() = PlaybackState::Paused; + let _ = app.emit(Events::PLAYBACK_STATE, "paused"); Ok(()) } -/// Stop playback +/// Stop playback. #[tauri::command] -pub async fn stop(state: State<'_, Arc>) -> Result<(), String> { +pub async fn stop(app: AppHandle, state: State<'_, Arc>) -> Result<(), String> { + { + let playback_guard = state.audio_playback.read(); + if let Some(ref handle) = *playback_guard { + let _ = handle.stop(); + } + } + *state.audio_playback.write() = None; *state.playback_state.write() = PlaybackState::Stopped; *state.playback_position.write() = 0.0; + *state.playback_samples_played.write() = 0; *state.highlighted_segment_index.write() = None; + + let _ = app.emit(Events::PLAYBACK_STATE, "stopped"); + let _ = app.emit(Events::PLAYBACK_POSITION, 0.0); + let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null); Ok(()) } -/// Seek to position +/// Seek to position. #[tauri::command] pub async fn seek( + app: AppHandle, state: State<'_, Arc>, position: f64, ) -> Result { - // Validate position if !position.is_finite() { return Err("Invalid seek position".to_string()); } - // Clamp to valid range let duration = *state.playback_duration.read(); let clamped = position.clamp(0.0, duration.max(0.0)); *state.playback_position.write() = clamped; - // Update highlight (or clear if no segment matches) - if let Some(index) = state.find_segment_at_position(clamped) { - *state.highlighted_segment_index.write() = Some(index); + // Update highlight + let highlighted_segment = state.find_segment_at_position(clamped); + *state.highlighted_segment_index.write() = highlighted_segment; + + if let Some(index) = highlighted_segment { + let _ = app.emit(Events::HIGHLIGHT_CHANGE, index); } else { - *state.highlighted_segment_index.write() = None; + let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null); } + let _ = app.emit(Events::PLAYBACK_POSITION, clamped); let playback_state = *state.playback_state.read(); - let highlighted_segment = *state.highlighted_segment_index.read(); Ok(PlaybackInfo { state: playback_state, @@ -84,18 +103,155 @@ pub async fn seek( }) } -/// Get current playback state +/// Get current playback state. #[tauri::command] pub async fn get_playback_state(state: State<'_, Arc>) -> Result { - let playback_state = *state.playback_state.read(); - let playback_position = *state.playback_position.read(); - let playback_duration = *state.playback_duration.read(); - let highlighted_segment = *state.highlighted_segment_index.read(); - Ok(PlaybackInfo { - state: playback_state, - position: playback_position, - duration: playback_duration, - highlighted_segment, + state: *state.playback_state.read(), + position: *state.playback_position.read(), + duration: *state.playback_duration.read(), + highlighted_segment: *state.highlighted_segment_index.read(), }) } + +// ============================================================================ +// Internal helpers +// ============================================================================ + +fn start_playback(app: &AppHandle, state: &Arc) -> Result<(), String> { + let audio_buffer = state.session_audio_buffer.read().clone(); + if audio_buffer.is_empty() { + return Err("No audio to play".to_string()); + } + + let sample_rate = *state.playback_sample_rate.read(); + if sample_rate == 0 { + return Err("Invalid sample rate for playback".to_string()); + } + + let handle = PlaybackHandle::new()?; + let started = handle.play(audio_buffer, sample_rate)?; + + *state.playback_duration.write() = started.duration; + *state.playback_position.write() = 0.0; + *state.playback_samples_played.write() = 0; + *state.playback_state.write() = PlaybackState::Playing; + + // Store handle + *state.audio_playback.write() = Some(handle); + + // Spawn position tracking thread + spawn_position_tracker( + app.clone(), + Arc::clone(state), + started.playing_flag, + started.position_atomic, + sample_rate, + 0, // Start from beginning + ); + + Ok(()) +} + +fn resume_playback(app: &AppHandle, state: &Arc) -> Result<(), String> { + let playback_guard = state.audio_playback.read(); + let handle = playback_guard + .as_ref() + .ok_or("No playback to resume")?; + + handle.resume()?; + drop(playback_guard); + + *state.playback_state.write() = PlaybackState::Playing; + + // Respawn position tracker with accumulated samples + let samples_played = *state.playback_samples_played.read(); + let sample_rate = *state.playback_sample_rate.read(); + + // We need to get the atomics again - create new ones for tracking + let playing_flag = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let position_atomic = Arc::new(std::sync::atomic::AtomicU64::new( + (*state.playback_position.read()).to_bits(), + )); + + spawn_position_tracker( + app.clone(), + Arc::clone(state), + playing_flag, + position_atomic, + sample_rate, + samples_played, + ); + Ok(()) +} + +fn spawn_position_tracker( + app: AppHandle, + state: Arc, + playing_flag: Arc, + position_atomic: Arc, + sample_rate: u32, + initial_samples: u64, +) { + let sample_rate = (sample_rate.max(1)) as f64; + + std::thread::spawn(move || { + let mut samples_played: u64 = initial_samples; + let mut last_highlight: Option = None; + + loop { + // Check playback state from AppState + let current_state = *state.playback_state.read(); + + if current_state == PlaybackState::Stopped { + break; + } + + if current_state == PlaybackState::Paused { + // Save accumulated samples for resume + *state.playback_samples_played.write() = samples_played; + playing_flag.store(false, Ordering::SeqCst); + // Exit thread when paused - will be respawned on resume + break; + } + + // Calculate position from samples played + samples_played += (sample_rate * POSITION_UPDATE_INTERVAL_MS as f64 / 1000.0) as u64; + let position = samples_played as f64 / sample_rate; + + // Update position atomic and state + position_atomic.store(position.to_bits(), Ordering::SeqCst); + *state.playback_position.write() = position; + + // Check if finished + let duration = *state.playback_duration.read(); + if position >= duration { + *state.playback_state.write() = PlaybackState::Stopped; + *state.playback_position.write() = duration; + *state.highlighted_segment_index.write() = None; + playing_flag.store(false, Ordering::SeqCst); + let _ = app.emit(Events::PLAYBACK_STATE, "stopped"); + let _ = app.emit(Events::PLAYBACK_POSITION, duration); + let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null); + break; + } + + // Update highlighted segment + let highlight = state.find_segment_at_position(position); + if highlight != last_highlight { + last_highlight = highlight; + *state.highlighted_segment_index.write() = highlight; + if let Some(index) = highlight { + let _ = app.emit(Events::HIGHLIGHT_CHANGE, index); + } else { + let _ = app.emit(Events::HIGHLIGHT_CHANGE, serde_json::Value::Null); + } + } + + // Emit position update + let _ = app.emit(Events::PLAYBACK_POSITION, position); + + std::thread::sleep(Duration::from_millis(POSITION_UPDATE_INTERVAL_MS)); + } + }); +} diff --git a/client/src-tauri/src/commands/playback_tests.rs b/client/src-tauri/src/commands/playback_tests.rs index 852f0a8..d1a0e60 100644 --- a/client/src-tauri/src/commands/playback_tests.rs +++ b/client/src-tauri/src/commands/playback_tests.rs @@ -4,7 +4,7 @@ #[cfg(test)] mod tests { - use crate::state::{PlaybackState, PlaybackInfo}; + use crate::state::{PlaybackInfo, PlaybackState}; #[test] fn playback_state_default() { diff --git a/client/src-tauri/src/commands/preferences.rs b/client/src-tauri/src/commands/preferences.rs new file mode 100644 index 0000000..13112a1 --- /dev/null +++ b/client/src-tauri/src/commands/preferences.rs @@ -0,0 +1,249 @@ +//! User preferences commands +//! +//! Handles getting and saving user preferences to persistent storage. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use keyring::Entry; +use tauri::State; + +use crate::constants::{preferences as prefs_config, secrets}; +use crate::state::AppState; + +use serde::{Deserialize, Serialize}; + +/// Summarization provider options +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SummarizationProvider { + #[default] + None, + Cloud, + Ollama, +} + +/// User preferences structure +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPreferences { + pub server_url: String, + pub data_directory: String, + pub encryption_enabled: bool, + pub auto_start_enabled: bool, + pub trigger_confidence_threshold: f32, + pub summarization_provider: SummarizationProvider, + pub cloud_api_key: String, + pub ollama_url: String, +} + +impl Default for UserPreferences { + fn default() -> Self { + Self { + server_url: "localhost:50051".to_string(), + data_directory: get_default_data_dir(), + encryption_enabled: true, + auto_start_enabled: true, + trigger_confidence_threshold: 0.7, + summarization_provider: SummarizationProvider::None, + cloud_api_key: String::new(), + ollama_url: "http://localhost:11434".to_string(), + } + } +} + +/// Get default data directory path +fn get_default_data_dir() -> String { + directories::ProjectDirs::from("com", "noteflow", "NoteFlow") + .map(|d| d.data_dir().to_string_lossy().to_string()) + .unwrap_or_else(|| { + directories::BaseDirs::new() + .map(|d| d.home_dir().join(".noteflow").to_string_lossy().to_string()) + .unwrap_or_else(|| "/tmp/noteflow".to_string()) + }) +} + +// ============================================================================ +// Preferences Persistence +// ============================================================================ + +/// Get the preferences file path +fn get_preferences_path() -> PathBuf { + directories::ProjectDirs::from("com", "noteflow", "NoteFlow") + .map(|d| d.config_dir().join(prefs_config::FILENAME)) + .unwrap_or_else(|| PathBuf::from("/tmp/noteflow").join(prefs_config::FILENAME)) +} + +/// Load preferences from disk +pub fn load_preferences_from_disk() -> HashMap { + let path = get_preferences_path(); + if path.exists() { + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + HashMap::new() + } +} + +/// Save preferences to disk +fn persist_preferences(prefs: &HashMap) -> Result<(), String> { + let path = get_preferences_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {e}"))?; + } + let json = serde_json::to_string_pretty(prefs) + .map_err(|e| format!("Failed to serialize preferences: {e}"))?; + std::fs::write(&path, json).map_err(|e| format!("Failed to save preferences: {e}")) +} + +// ============================================================================ +// Secure API Key Storage +// ============================================================================ + +/// Get API key from system keychain +fn get_api_key() -> Option { + Entry::new(secrets::KEYCHAIN_SERVICE, secrets::API_KEY_USERNAME) + .ok() + .and_then(|entry| entry.get_password().ok()) + .filter(|s| !s.is_empty()) +} + +/// Store API key in system keychain +fn set_api_key(key: &str) -> Result<(), String> { + let entry = Entry::new(secrets::KEYCHAIN_SERVICE, secrets::API_KEY_USERNAME) + .map_err(|e| format!("Keychain access failed: {e}"))?; + + if key.is_empty() { + // Delete credential if key is empty + entry.delete_password().ok(); + } else { + entry + .set_password(key) + .map_err(|e| format!("Failed to store API key: {e}"))?; + } + Ok(()) +} + +/// Mask API key for display (show first/last 4 chars) +fn mask_api_key(key: &str) -> String { + if key.is_empty() { + String::new() + } else if key.len() <= 8 { + "*".repeat(key.len()) + } else { + format!("{}...{}", &key[..4], &key[key.len() - 4..]) + } +} + +/// Get user preferences +#[tauri::command] +pub async fn get_preferences(state: State<'_, Arc>) -> Result { + let prefs = state.preferences.read(); + + // Get API key from keychain and mask it for display + let masked_api_key = get_api_key().map(|k| mask_api_key(&k)).unwrap_or_default(); + + // Reconstruct preferences from stored values + Ok(UserPreferences { + server_url: prefs + .get("server_url") + .and_then(|v| v.as_str()) + .unwrap_or("localhost:50051") + .to_string(), + data_directory: prefs + .get("data_directory") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(get_default_data_dir), + encryption_enabled: prefs + .get("encryption_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + auto_start_enabled: prefs + .get("auto_start_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + trigger_confidence_threshold: prefs + .get("trigger_confidence_threshold") + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(0.7), + summarization_provider: prefs + .get("summarization_provider") + .and_then(|v| v.as_str()) + .map(|s| match s { + "cloud" => SummarizationProvider::Cloud, + "ollama" => SummarizationProvider::Ollama, + _ => SummarizationProvider::None, + }) + .unwrap_or_default(), + cloud_api_key: masked_api_key, + ollama_url: prefs + .get("ollama_url") + .and_then(|v| v.as_str()) + .unwrap_or("http://localhost:11434") + .to_string(), + }) +} + +/// Save user preferences +#[tauri::command] +pub async fn save_preferences( + state: State<'_, Arc>, + preferences: UserPreferences, +) -> Result<(), String> { + // Store API key in keychain if user entered a new one (not masked) + let api_key = &preferences.cloud_api_key; + if !api_key.contains("...") && !api_key.contains("*") { + set_api_key(api_key)?; + } + + let mut prefs = state.preferences.write(); + + prefs.insert( + "server_url".to_string(), + serde_json::json!(preferences.server_url), + ); + prefs.insert( + "data_directory".to_string(), + serde_json::json!(preferences.data_directory), + ); + prefs.insert( + "encryption_enabled".to_string(), + serde_json::json!(preferences.encryption_enabled), + ); + prefs.insert( + "auto_start_enabled".to_string(), + serde_json::json!(preferences.auto_start_enabled), + ); + prefs.insert( + "trigger_confidence_threshold".to_string(), + serde_json::json!(preferences.trigger_confidence_threshold), + ); + prefs.insert( + "summarization_provider".to_string(), + serde_json::json!(match preferences.summarization_provider { + SummarizationProvider::Cloud => "cloud", + SummarizationProvider::Ollama => "ollama", + SummarizationProvider::None => "none", + }), + ); + prefs.insert( + "ollama_url".to_string(), + serde_json::json!(preferences.ollama_url), + ); + // Note: cloud_api_key is stored in keychain, not in preferences file + + // Update meetings directory if changed + let meetings_dir = std::path::PathBuf::from(&preferences.data_directory).join("meetings"); + *state.meetings_dir.write() = meetings_dir; + + // Persist to disk + persist_preferences(&prefs)?; + + Ok(()) +} diff --git a/client/src-tauri/src/commands/recording.rs b/client/src-tauri/src/commands/recording.rs index 0add73d..03dd629 100644 --- a/client/src-tauri/src/commands/recording.rs +++ b/client/src-tauri/src/commands/recording.rs @@ -24,7 +24,11 @@ pub async fn start_recording( } // Create meeting via gRPC - let meeting = state.grpc_client.create_meeting(&title, None).await?; + let meeting = state + .grpc_client + .create_meeting(&title, None) + .await + .map_err(|e| e.to_string())?; // Start gRPC streaming - if this fails, clean up the meeting reference if let Err(e) = state.grpc_client.start_streaming(&meeting.id).await { @@ -35,7 +39,7 @@ pub async fn start_recording( let _ = state.grpc_client.stop_meeting(&meeting.id).await; let _ = state.grpc_client.delete_meeting(&meeting.id).await; - return Err(e); + return Err(e.to_string()); } // All operations succeeded - update state atomically @@ -77,7 +81,7 @@ pub async fn stop_recording(state: State<'_, Arc>) -> Result { - *state.summary_error.write() = Some(e.clone()); - Err(e) + let error_msg = e.to_string(); + *state.summary_error.write() = Some(error_msg.clone()); + Err(error_msg) } } } diff --git a/client/src-tauri/src/commands/triggers.rs b/client/src-tauri/src/commands/triggers.rs index d68f004..53f7752 100644 --- a/client/src-tauri/src/commands/triggers.rs +++ b/client/src-tauri/src/commands/triggers.rs @@ -103,7 +103,7 @@ pub async fn accept_trigger( // Keep decision/pending so the user can retry or dismiss. *state.trigger_pending.write() = true; - return Err(e); + return Err(e.to_string()); } // Clear pending UI state only after successful start to avoid losing the prompt on transient errors diff --git a/client/src-tauri/src/config.rs b/client/src-tauri/src/config.rs index 916f735..97d9c2b 100644 --- a/client/src-tauri/src/config.rs +++ b/client/src-tauri/src/config.rs @@ -183,10 +183,10 @@ pub struct TriggerConfig { impl TriggerConfig { fn from_env() -> Self { - let auto_start_threshold = env::var("NOTEFLOW_AUTO_START_THRESHOLD") + let auto_start_threshold: f32 = env::var("NOTEFLOW_AUTO_START_THRESHOLD") .ok() .and_then(|v| v.parse().ok()) - .unwrap_or(0.8) + .unwrap_or(0.8_f32) .clamp(0.0, 1.0); // Ensure threshold is within valid range Self { diff --git a/client/src-tauri/src/constants.rs b/client/src-tauri/src/constants.rs index 2b856c0..2e44c6a 100644 --- a/client/src-tauri/src/constants.rs +++ b/client/src-tauri/src/constants.rs @@ -29,6 +29,9 @@ pub mod events { pub const DIARIZATION_PROGRESS: &str = "DIARIZATION_PROGRESS"; } +// Backwards-compatible alias for existing call sites. +pub use events as Events; + /// gRPC connection settings pub mod grpc { use std::time::Duration; @@ -75,6 +78,20 @@ pub mod triggers { pub const POLL_INTERVAL: Duration = Duration::from_secs(5); } +/// Preferences settings +pub mod preferences { + /// Preferences filename + pub const FILENAME: &str = "preferences.json"; +} + +/// Secrets keychain settings +pub mod secrets { + /// Keychain service name for secrets + pub const KEYCHAIN_SERVICE: &str = "com.noteflow.secrets"; + /// Keychain username for cloud API key + pub const API_KEY_USERNAME: &str = "cloud_api_key"; +} + #[cfg(test)] mod tests { use super::*; @@ -82,9 +99,15 @@ mod tests { #[test] fn event_names_are_uppercase() { // Event names should be SCREAMING_SNAKE_CASE for convention - assert!(events::TRANSCRIPT_UPDATE.chars().all(|c| c.is_uppercase() || c == '_')); - assert!(events::AUDIO_LEVEL.chars().all(|c| c.is_uppercase() || c == '_')); - assert!(events::CONNECTION_CHANGE.chars().all(|c| c.is_uppercase() || c == '_')); + assert!(events::TRANSCRIPT_UPDATE + .chars() + .all(|c| c.is_uppercase() || c == '_')); + assert!(events::AUDIO_LEVEL + .chars() + .all(|c| c.is_uppercase() || c == '_')); + assert!(events::CONNECTION_CHANGE + .chars() + .all(|c| c.is_uppercase() || c == '_')); } #[test] diff --git a/client/src-tauri/src/crypto/mod.rs b/client/src-tauri/src/crypto/mod.rs index 2fefee5..a89c3bf 100644 --- a/client/src-tauri/src/crypto/mod.rs +++ b/client/src-tauri/src/crypto/mod.rs @@ -12,11 +12,18 @@ use rand::Rng; use crate::constants::crypto as crypto_config; /// Crypto box for encryption/decryption operations -#[derive(Debug)] pub struct CryptoBox { cipher: Aes256Gcm, } +impl std::fmt::Debug for CryptoBox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CryptoBox") + .field("cipher", &"[Aes256Gcm]") + .finish() + } +} + impl CryptoBox { /// Create a new CryptoBox with a key from keychain or generate new pub fn new() -> Result { @@ -30,9 +37,11 @@ impl CryptoBox { /// Get existing key from keychain or generate a new one fn get_or_create_key() -> Result, String> { // Try to get key from keychain - let keyring = - keyring::Entry::new(crypto_config::KEYCHAIN_SERVICE, crypto_config::KEYCHAIN_USERNAME) - .map_err(|e| format!("Failed to access keychain: {}", e))?; + let keyring = keyring::Entry::new( + crypto_config::KEYCHAIN_SERVICE, + crypto_config::KEYCHAIN_USERNAME, + ) + .map_err(|e| format!("Failed to access keychain: {}", e))?; match keyring.get_password() { Ok(key_hex) => { diff --git a/client/src-tauri/src/events/mod.rs b/client/src-tauri/src/events/mod.rs index 1753d61..a00c2ab 100644 --- a/client/src-tauri/src/events/mod.rs +++ b/client/src-tauri/src/events/mod.rs @@ -4,7 +4,7 @@ //! from the Rust backend to the React frontend. use serde::Serialize; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter}; use crate::constants::events as event_names; use crate::grpc::types::TranscriptUpdate; diff --git a/client/src-tauri/src/grpc/client.rs b/client/src-tauri/src/grpc/client.rs index 6653d05..99c41ba 100644 --- a/client/src-tauri/src/grpc/client.rs +++ b/client/src-tauri/src/grpc/client.rs @@ -10,6 +10,7 @@ use parking_lot::RwLock; use tokio::sync::Mutex; use tonic::transport::Channel; +use super::noteflow::{self as proto, note_flow_service_client::NoteFlowServiceClient}; use super::types::{ AnnotationInfo, AnnotationType, AudioChunk, DiarizationResult, ExportFormat, ExportResult, JobStatus, MeetingDetails, MeetingInfo, MeetingState, RenameSpeakerResult, ServerInfo, @@ -18,6 +19,20 @@ use super::types::{ use crate::constants::grpc as grpc_config; use crate::helpers::new_id; +/// Convert proto Annotation to local AnnotationInfo +fn annotation_from_proto(a: proto::Annotation) -> AnnotationInfo { + AnnotationInfo { + id: a.id, + meeting_id: a.meeting_id, + annotation_type: AnnotationType::from(a.annotation_type), + text: a.text, + start_time: a.start_time, + end_time: a.end_time, + segment_ids: a.segment_ids, + created_at: a.created_at, + } +} + /// gRPC client error type #[derive(Debug, Clone, thiserror::Error)] pub enum GrpcError { @@ -117,6 +132,17 @@ impl GrpcClient { } } + /// Get tonic client from existing channel (no new state needed) + fn tonic_client(&self) -> Result, GrpcError> { + self.inner + .connection + .read() + .channel + .clone() + .map(NoteFlowServiceClient::new) + .ok_or(GrpcError::NotConnected) + } + /// Connect to gRPC server with timeout pub async fn connect(&self, address: &str) -> Result { let endpoint = Channel::from_shared(format!("http://{}", address)) @@ -181,9 +207,23 @@ impl GrpcClient { /// Get server information pub async fn get_server_info(&self) -> Result { self.require_connection()?; + let mut client = self.tonic_client()?; - // TODO: Implement actual gRPC call when proto is compiled - Ok(ServerInfo::default()) + let response = client + .get_server_info(proto::ServerInfoRequest {}) + .await + .map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?; + + let info = response.into_inner(); + Ok(ServerInfo { + version: info.version, + asr_model: info.asr_model, + asr_ready: info.asr_ready, + uptime_seconds: info.uptime_seconds, + active_meetings: info.active_meetings as u32, + diarization_enabled: info.diarization_enabled, + diarization_ready: info.diarization_ready, + }) } /// Create a new meeting @@ -288,57 +328,116 @@ impl GrpcClient { segment_ids: Option>, ) -> Result { self.require_connection()?; + let mut client = self.tonic_client()?; - // TODO: Implement actual gRPC call - Ok(AnnotationInfo::new( - meeting_id, - annotation_type, - text, + let request = proto::AddAnnotationRequest { + meeting_id: meeting_id.to_string(), + annotation_type: annotation_type as i32, + text: text.to_string(), start_time, end_time, - segment_ids, - )) + segment_ids: segment_ids.unwrap_or_default(), + }; + + let response = client + .add_annotation(request) + .await + .map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?; + + Ok(annotation_from_proto(response.into_inner())) } /// Get annotation by ID - pub async fn get_annotation(&self, _annotation_id: &str) -> Result { + pub async fn get_annotation(&self, annotation_id: &str) -> Result { self.require_connection()?; - Err(GrpcError::NotImplemented) + let mut client = self.tonic_client()?; + + let request = proto::GetAnnotationRequest { + annotation_id: annotation_id.to_string(), + }; + + let response = client + .get_annotation(request) + .await + .map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?; + + Ok(annotation_from_proto(response.into_inner())) } /// List annotations for a meeting pub async fn list_annotations( &self, - _meeting_id: &str, - _start_time: f64, - _end_time: f64, + meeting_id: &str, + start_time: f64, + end_time: f64, ) -> Result, GrpcError> { self.require_connection()?; + let mut client = self.tonic_client()?; - // TODO: Implement actual gRPC call - Ok(vec![]) + let request = proto::ListAnnotationsRequest { + meeting_id: meeting_id.to_string(), + start_time, + end_time, + }; + + let response = client + .list_annotations(request) + .await + .map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?; + + Ok(response + .into_inner() + .annotations + .into_iter() + .map(annotation_from_proto) + .collect()) } /// Update an existing annotation pub async fn update_annotation( &self, - _annotation_id: &str, - _annotation_type: Option, - _text: Option<&str>, - _start_time: Option, - _end_time: Option, - _segment_ids: Option>, + annotation_id: &str, + annotation_type: Option, + text: Option<&str>, + start_time: Option, + end_time: Option, + segment_ids: Option>, ) -> Result { self.require_connection()?; - Err(GrpcError::NotImplemented) + let mut client = self.tonic_client()?; + + let request = proto::UpdateAnnotationRequest { + annotation_id: annotation_id.to_string(), + annotation_type: annotation_type.map(|t| t as i32).unwrap_or(0), + text: text.unwrap_or("").to_string(), + start_time: start_time.unwrap_or(0.0), + end_time: end_time.unwrap_or(0.0), + segment_ids: segment_ids.unwrap_or_default(), + }; + + let response = client + .update_annotation(request) + .await + .map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?; + + Ok(annotation_from_proto(response.into_inner())) } /// Delete an annotation - pub async fn delete_annotation(&self, _annotation_id: &str) -> Result { + pub async fn delete_annotation(&self, annotation_id: &str) -> Result { self.require_connection()?; + let mut client = self.tonic_client()?; - // TODO: Implement actual gRPC call - Ok(true) + let request = proto::DeleteAnnotationRequest { + annotation_id: annotation_id.to_string(), + }; + + let response = client + .delete_annotation(request) + .await + .map_err(|e| GrpcError::ConnectionFailed(e.to_string()))?; + + Ok(response.into_inner().success) } /// Generate AI summary for a meeting diff --git a/client/src-tauri/src/grpc/client_tests.rs b/client/src-tauri/src/grpc/client_tests.rs index c4337c3..bf62619 100644 --- a/client/src-tauri/src/grpc/client_tests.rs +++ b/client/src-tauri/src/grpc/client_tests.rs @@ -1,29 +1,28 @@ -//! Unit tests for gRPC client +//! Unit tests for gRPC types //! -//! These tests verify client state management and error handling. -//! Actual gRPC calls require a running server and are tested in integration tests. +//! These tests verify type construction and serialization. +//! Actual gRPC calls are tested in tests/grpc_integration.rs #[cfg(test)] mod tests { use crate::grpc::types::*; #[test] - fn meeting_info_construction() { - let meeting = MeetingInfo { - id: "meeting-123".to_string(), - title: "Test Meeting".to_string(), - state: MeetingState::Recording, - created_at: 1234567890.0, - updated_at: 1234567891.0, - duration_secs: 3600.0, - segment_count: 10, - has_summary: false, - }; + fn meeting_info_new() { + let meeting = MeetingInfo::new("Test Meeting"); - assert_eq!(meeting.id, "meeting-123"); + assert!(!meeting.id.is_empty()); assert_eq!(meeting.title, "Test Meeting"); assert_eq!(meeting.state, MeetingState::Recording); - assert_eq!(meeting.segment_count, 10); + assert!(meeting.created_at > 0.0); + } + + #[test] + fn meeting_info_stopped() { + let meeting = MeetingInfo::stopped("meeting-123"); + + assert_eq!(meeting.id, "meeting-123"); + assert_eq!(meeting.state, MeetingState::Stopped); } #[test] @@ -47,35 +46,38 @@ mod tests { } #[test] - fn server_info_construction() { - let info = ServerInfo { - version: "1.0.0".to_string(), - asr_model: "whisper-large".to_string(), - capabilities: vec!["streaming".to_string(), "diarization".to_string()], - }; - - assert_eq!(info.version, "1.0.0"); - assert_eq!(info.capabilities.len(), 2); - assert!(info.capabilities.contains(&"streaming".to_string())); + fn meeting_state_from_i32() { + assert_eq!(MeetingState::from(1), MeetingState::Created); + assert_eq!(MeetingState::from(2), MeetingState::Recording); + assert_eq!(MeetingState::from(3), MeetingState::Stopped); + assert_eq!(MeetingState::from(4), MeetingState::Completed); + assert_eq!(MeetingState::from(5), MeetingState::Error); + assert_eq!(MeetingState::from(99), MeetingState::Unspecified); } #[test] - fn segment_info_construction() { + fn server_info_default() { + let info = ServerInfo::default(); + + assert!(info.version.is_empty()); + assert!(!info.asr_ready); + } + + #[test] + fn segment_construction() { let segment = Segment { - id: "seg-1".to_string(), - meeting_id: "meeting-123".to_string(), - speaker: Some("Speaker 1".to_string()), + segment_id: 1, text: "Hello world".to_string(), start_time: 0.0, end_time: 5.0, - confidence: 0.95, - is_partial: false, - word_timings: vec![], + language: "en".to_string(), + speaker_id: "SPEAKER_00".to_string(), + speaker_confidence: 0.95, + words: vec![], }; assert_eq!(segment.text, "Hello world"); - assert!((segment.confidence - 0.95).abs() < f32::EPSILON); - assert!(!segment.is_partial); + assert!((segment.speaker_confidence - 0.95).abs() < f64::EPSILON); } #[test] @@ -84,7 +86,7 @@ mod tests { word: "hello".to_string(), start_time: 0.0, end_time: 0.5, - confidence: 0.98, + probability: 0.98, }; assert_eq!(timing.word, "hello"); @@ -92,56 +94,113 @@ mod tests { } #[test] - fn annotation_info_construction() { - let annotation = AnnotationInfo { - id: "ann-1".to_string(), - segment_id: "seg-1".to_string(), - content: "Important action item".to_string(), - annotation_type: "action_item".to_string(), - created_at: 1234567890.0, - }; - - assert_eq!(annotation.annotation_type, "action_item"); - assert_eq!(annotation.content, "Important action item"); + fn annotation_type_from_str() { + assert_eq!(AnnotationType::from("action_item"), AnnotationType::ActionItem); + assert_eq!(AnnotationType::from("decision"), AnnotationType::Decision); + assert_eq!(AnnotationType::from("note"), AnnotationType::Note); + assert_eq!(AnnotationType::from("risk"), AnnotationType::Risk); + assert_eq!(AnnotationType::from("unknown"), AnnotationType::Unspecified); } #[test] - fn summary_info_construction() { - let summary = SummaryInfo { - id: "sum-1".to_string(), + fn annotation_type_from_i32() { + assert_eq!(AnnotationType::from(1), AnnotationType::ActionItem); + assert_eq!(AnnotationType::from(2), AnnotationType::Decision); + assert_eq!(AnnotationType::from(3), AnnotationType::Note); + assert_eq!(AnnotationType::from(4), AnnotationType::Risk); + assert_eq!(AnnotationType::from(99), AnnotationType::Unspecified); + } + + #[test] + fn annotation_info_construction() { + let annotation = AnnotationInfo { + id: "ann-1".to_string(), meeting_id: "meeting-123".to_string(), - content: "Meeting summary content".to_string(), - key_points: vec!["Point 1".to_string(), "Point 2".to_string()], - action_items: vec!["Action 1".to_string()], - decisions: vec![], + annotation_type: AnnotationType::ActionItem, + text: "Follow up with team".to_string(), + start_time: 10.0, + end_time: 15.0, + segment_ids: vec![1, 2], created_at: 1234567890.0, }; - assert_eq!(summary.key_points.len(), 2); - assert_eq!(summary.action_items.len(), 1); - assert!(summary.decisions.is_empty()); + assert_eq!(annotation.annotation_type, AnnotationType::ActionItem); + assert_eq!(annotation.text, "Follow up with team"); + assert_eq!(annotation.segment_ids.len(), 2); } #[test] fn timestamped_audio_construction() { let audio = TimestampedAudio { - data: vec![0.1, 0.2, -0.1, -0.2], + frames: vec![0.1, 0.2, -0.1, -0.2], timestamp: 1234567890.0, duration: 0.1, }; - assert_eq!(audio.data.len(), 4); + assert_eq!(audio.frames.len(), 4); assert!((audio.duration - 0.1).abs() < f64::EPSILON); } #[test] - fn connection_status_construction() { - let status = ConnectionStatus { - connected: true, - address: "localhost:50051".to_string(), + fn export_format_from_str() { + assert_eq!(ExportFormat::from("html"), ExportFormat::Html); + assert_eq!(ExportFormat::from("markdown"), ExportFormat::Markdown); + assert_eq!(ExportFormat::from("unknown"), ExportFormat::Markdown); // default + } + + #[test] + fn export_result_empty() { + let result = ExportResult::empty(ExportFormat::Markdown); + + assert!(result.content.is_empty()); + assert_eq!(result.format_name, "markdown"); + assert_eq!(result.file_extension, "md"); + + let result = ExportResult::empty(ExportFormat::Html); + assert_eq!(result.format_name, "html"); + assert_eq!(result.file_extension, "html"); + } + + #[test] + fn job_status_from_i32() { + assert_eq!(JobStatus::from(1), JobStatus::Queued); + assert_eq!(JobStatus::from(2), JobStatus::Running); + assert_eq!(JobStatus::from(3), JobStatus::Completed); + assert_eq!(JobStatus::from(4), JobStatus::Failed); + assert_eq!(JobStatus::from(99), JobStatus::Unspecified); + } + + #[test] + fn update_type_from_i32() { + assert_eq!(UpdateType::from(1), UpdateType::Partial); + assert_eq!(UpdateType::from(2), UpdateType::Final); + assert_eq!(UpdateType::from(3), UpdateType::VadStart); + assert_eq!(UpdateType::from(4), UpdateType::VadEnd); + assert_eq!(UpdateType::from(99), UpdateType::Unspecified); + } + + #[test] + fn meeting_details_empty() { + let details = MeetingDetails::empty("meeting-123"); + + assert_eq!(details.meeting.id, "meeting-123"); + assert_eq!(details.meeting.state, MeetingState::Stopped); + assert!(details.segments.is_empty()); + assert!(details.summary.is_none()); + } + + #[test] + fn audio_device_info_construction() { + let device = AudioDeviceInfo { + id: 12345, + name: "USB Microphone".to_string(), + channels: 1, + sample_rate: 48000, + is_default: true, }; - assert!(status.connected); - assert_eq!(status.address, "localhost:50051"); + assert_eq!(device.id, 12345); + assert_eq!(device.name, "USB Microphone"); + assert!(device.is_default); } } diff --git a/client/src-tauri/src/grpc/mod.rs b/client/src-tauri/src/grpc/mod.rs index 45f8e07..54720bc 100644 --- a/client/src-tauri/src/grpc/mod.rs +++ b/client/src-tauri/src/grpc/mod.rs @@ -1,19 +1,14 @@ //! gRPC client module for NoteFlow server communication pub mod client; +#[allow(clippy::all)] +#[allow(dead_code)] +pub mod noteflow; pub mod types; // Re-export main types pub use client::GrpcClient; pub use types::*; -// Include generated protobuf code (will be generated by build.rs) -#[allow(clippy::all)] -#[allow(dead_code)] -pub mod proto { - // Placeholder - actual proto code will be generated by tonic-build - // For now, we define the types manually to allow compilation -} - #[cfg(test)] mod client_tests; diff --git a/client/src-tauri/src/grpc/noteflow.rs b/client/src-tauri/src/grpc/noteflow.rs new file mode 100644 index 0000000..67f51e6 --- /dev/null +++ b/client/src-tauri/src/grpc/noteflow.rs @@ -0,0 +1,1205 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AudioChunk { + /// Meeting ID this audio belongs to + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Raw audio data (float32, mono, 16kHz expected) + #[prost(bytes = "vec", tag = "2")] + pub audio_data: ::prost::alloc::vec::Vec, + /// Timestamp when audio was captured (monotonic, seconds) + #[prost(double, tag = "3")] + pub timestamp: f64, + /// Sample rate in Hz (default 16000) + #[prost(int32, tag = "4")] + pub sample_rate: i32, + /// Number of channels (default 1 for mono) + #[prost(int32, tag = "5")] + pub channels: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TranscriptUpdate { + /// Meeting ID this transcript belongs to + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Type of update + #[prost(enumeration = "UpdateType", tag = "2")] + pub update_type: i32, + /// For partial updates - tentative transcript text + #[prost(string, tag = "3")] + pub partial_text: ::prost::alloc::string::String, + /// For final segments - confirmed transcript + #[prost(message, optional, tag = "4")] + pub segment: ::core::option::Option, + /// Server-side processing timestamp + #[prost(double, tag = "5")] + pub server_timestamp: f64, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FinalSegment { + /// Segment ID (sequential within meeting) + #[prost(int32, tag = "1")] + pub segment_id: i32, + /// Transcript text + #[prost(string, tag = "2")] + pub text: ::prost::alloc::string::String, + /// Start time relative to meeting start (seconds) + #[prost(double, tag = "3")] + pub start_time: f64, + /// End time relative to meeting start (seconds) + #[prost(double, tag = "4")] + pub end_time: f64, + /// Word-level timestamps + #[prost(message, repeated, tag = "5")] + pub words: ::prost::alloc::vec::Vec, + /// Detected language + #[prost(string, tag = "6")] + pub language: ::prost::alloc::string::String, + /// Language detection confidence (0.0-1.0) + #[prost(float, tag = "7")] + pub language_confidence: f32, + /// Average log probability (quality indicator) + #[prost(float, tag = "8")] + pub avg_logprob: f32, + /// Probability that segment contains no speech + #[prost(float, tag = "9")] + pub no_speech_prob: f32, + /// Speaker identification (from diarization) + #[prost(string, tag = "10")] + pub speaker_id: ::prost::alloc::string::String, + /// Speaker assignment confidence (0.0-1.0) + #[prost(float, tag = "11")] + pub speaker_confidence: f32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct WordTiming { + #[prost(string, tag = "1")] + pub word: ::prost::alloc::string::String, + #[prost(double, tag = "2")] + pub start_time: f64, + #[prost(double, tag = "3")] + pub end_time: f64, + #[prost(float, tag = "4")] + pub probability: f32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Meeting { + /// Unique meeting identifier + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// User-provided title + #[prost(string, tag = "2")] + pub title: ::prost::alloc::string::String, + /// Meeting state + #[prost(enumeration = "MeetingState", tag = "3")] + pub state: i32, + /// Creation timestamp (Unix epoch seconds) + #[prost(double, tag = "4")] + pub created_at: f64, + /// Start timestamp (when recording began) + #[prost(double, tag = "5")] + pub started_at: f64, + /// End timestamp (when recording stopped) + #[prost(double, tag = "6")] + pub ended_at: f64, + /// Duration in seconds + #[prost(double, tag = "7")] + pub duration_seconds: f64, + /// Full transcript segments + #[prost(message, repeated, tag = "8")] + pub segments: ::prost::alloc::vec::Vec, + /// Generated summary (if available) + #[prost(message, optional, tag = "9")] + pub summary: ::core::option::Option, + /// Metadata + #[prost(map = "string, string", tag = "10")] + pub metadata: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost::alloc::string::String, + >, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateMeetingRequest { + /// Optional title (generated if not provided) + #[prost(string, tag = "1")] + pub title: ::prost::alloc::string::String, + /// Optional metadata + #[prost(map = "string, string", tag = "2")] + pub metadata: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost::alloc::string::String, + >, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct StopMeetingRequest { + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListMeetingsRequest { + /// Optional filter by state + #[prost(enumeration = "MeetingState", repeated, tag = "1")] + pub states: ::prost::alloc::vec::Vec, + /// Pagination + #[prost(int32, tag = "2")] + pub limit: i32, + #[prost(int32, tag = "3")] + pub offset: i32, + /// Sort order + #[prost(enumeration = "SortOrder", tag = "4")] + pub sort_order: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListMeetingsResponse { + #[prost(message, repeated, tag = "1")] + pub meetings: ::prost::alloc::vec::Vec, + #[prost(int32, tag = "2")] + pub total_count: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetMeetingRequest { + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Whether to include full transcript segments + #[prost(bool, tag = "2")] + pub include_segments: bool, + /// Whether to include summary + #[prost(bool, tag = "3")] + pub include_summary: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteMeetingRequest { + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct DeleteMeetingResponse { + #[prost(bool, tag = "1")] + pub success: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Summary { + /// Meeting this summary belongs to + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Executive summary (2-3 sentences) + #[prost(string, tag = "2")] + pub executive_summary: ::prost::alloc::string::String, + /// Key points / highlights + #[prost(message, repeated, tag = "3")] + pub key_points: ::prost::alloc::vec::Vec, + /// Action items extracted + #[prost(message, repeated, tag = "4")] + pub action_items: ::prost::alloc::vec::Vec, + /// Generated timestamp + #[prost(double, tag = "5")] + pub generated_at: f64, + /// Model/version used for generation + #[prost(string, tag = "6")] + pub model_version: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct KeyPoint { + /// The key point text + #[prost(string, tag = "1")] + pub text: ::prost::alloc::string::String, + /// Segment IDs that support this point (evidence linking) + #[prost(int32, repeated, tag = "2")] + pub segment_ids: ::prost::alloc::vec::Vec, + /// Timestamp range this point covers + #[prost(double, tag = "3")] + pub start_time: f64, + #[prost(double, tag = "4")] + pub end_time: f64, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ActionItem { + /// Action item text + #[prost(string, tag = "1")] + pub text: ::prost::alloc::string::String, + /// Assigned to (if mentioned) + #[prost(string, tag = "2")] + pub assignee: ::prost::alloc::string::String, + /// Due date (if mentioned, Unix epoch) + #[prost(double, tag = "3")] + pub due_date: f64, + /// Priority level + #[prost(enumeration = "Priority", tag = "4")] + pub priority: i32, + /// Segment IDs that mention this action + #[prost(int32, repeated, tag = "5")] + pub segment_ids: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GenerateSummaryRequest { + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Force regeneration even if summary exists + #[prost(bool, tag = "2")] + pub force_regenerate: bool, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct ServerInfoRequest {} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ServerInfo { + /// Server version + #[prost(string, tag = "1")] + pub version: ::prost::alloc::string::String, + /// ASR model loaded + #[prost(string, tag = "2")] + pub asr_model: ::prost::alloc::string::String, + /// Whether ASR is ready + #[prost(bool, tag = "3")] + pub asr_ready: bool, + /// Supported sample rates + #[prost(int32, repeated, tag = "4")] + pub supported_sample_rates: ::prost::alloc::vec::Vec, + /// Maximum audio chunk size in bytes + #[prost(int32, tag = "5")] + pub max_chunk_size: i32, + /// Server uptime in seconds + #[prost(double, tag = "6")] + pub uptime_seconds: f64, + /// Number of active meetings + #[prost(int32, tag = "7")] + pub active_meetings: i32, + /// Whether diarization is enabled + #[prost(bool, tag = "8")] + pub diarization_enabled: bool, + /// Whether diarization models are ready + #[prost(bool, tag = "9")] + pub diarization_ready: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Annotation { + /// Unique annotation identifier + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// Meeting this annotation belongs to + #[prost(string, tag = "2")] + pub meeting_id: ::prost::alloc::string::String, + /// Type of annotation + #[prost(enumeration = "AnnotationType", tag = "3")] + pub annotation_type: i32, + /// Annotation text + #[prost(string, tag = "4")] + pub text: ::prost::alloc::string::String, + /// Start time relative to meeting start (seconds) + #[prost(double, tag = "5")] + pub start_time: f64, + /// End time relative to meeting start (seconds) + #[prost(double, tag = "6")] + pub end_time: f64, + /// Linked segment IDs (evidence linking) + #[prost(int32, repeated, tag = "7")] + pub segment_ids: ::prost::alloc::vec::Vec, + /// Creation timestamp (Unix epoch seconds) + #[prost(double, tag = "8")] + pub created_at: f64, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AddAnnotationRequest { + /// Meeting ID to add annotation to + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Type of annotation + #[prost(enumeration = "AnnotationType", tag = "2")] + pub annotation_type: i32, + /// Annotation text + #[prost(string, tag = "3")] + pub text: ::prost::alloc::string::String, + /// Start time relative to meeting start (seconds) + #[prost(double, tag = "4")] + pub start_time: f64, + /// End time relative to meeting start (seconds) + #[prost(double, tag = "5")] + pub end_time: f64, + /// Optional linked segment IDs + #[prost(int32, repeated, tag = "6")] + pub segment_ids: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetAnnotationRequest { + #[prost(string, tag = "1")] + pub annotation_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListAnnotationsRequest { + /// Meeting ID to list annotations for + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Optional time range filter + #[prost(double, tag = "2")] + pub start_time: f64, + #[prost(double, tag = "3")] + pub end_time: f64, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListAnnotationsResponse { + #[prost(message, repeated, tag = "1")] + pub annotations: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpdateAnnotationRequest { + /// Annotation ID to update + #[prost(string, tag = "1")] + pub annotation_id: ::prost::alloc::string::String, + /// Updated type (optional, keeps existing if not set) + #[prost(enumeration = "AnnotationType", tag = "2")] + pub annotation_type: i32, + /// Updated text (optional, keeps existing if empty) + #[prost(string, tag = "3")] + pub text: ::prost::alloc::string::String, + /// Updated start time (optional, keeps existing if 0) + #[prost(double, tag = "4")] + pub start_time: f64, + /// Updated end time (optional, keeps existing if 0) + #[prost(double, tag = "5")] + pub end_time: f64, + /// Updated segment IDs (replaces existing) + #[prost(int32, repeated, tag = "6")] + pub segment_ids: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DeleteAnnotationRequest { + #[prost(string, tag = "1")] + pub annotation_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct DeleteAnnotationResponse { + #[prost(bool, tag = "1")] + pub success: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ExportTranscriptRequest { + /// Meeting ID to export + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Export format + #[prost(enumeration = "ExportFormat", tag = "2")] + pub format: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ExportTranscriptResponse { + /// Exported content + #[prost(string, tag = "1")] + pub content: ::prost::alloc::string::String, + /// Format name + #[prost(string, tag = "2")] + pub format_name: ::prost::alloc::string::String, + /// Suggested file extension + #[prost(string, tag = "3")] + pub file_extension: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RefineSpeakerDiarizationRequest { + /// Meeting ID to run diarization on + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Optional known number of speakers (auto-detect if not set or 0) + #[prost(int32, tag = "2")] + pub num_speakers: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RefineSpeakerDiarizationResponse { + /// Number of segments updated with speaker labels + #[prost(int32, tag = "1")] + pub segments_updated: i32, + /// Distinct speaker IDs found + #[prost(string, repeated, tag = "2")] + pub speaker_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Error message if diarization failed + #[prost(string, tag = "3")] + pub error_message: ::prost::alloc::string::String, + /// Background job identifier (empty if request failed) + #[prost(string, tag = "4")] + pub job_id: ::prost::alloc::string::String, + /// Current job status + #[prost(enumeration = "JobStatus", tag = "5")] + pub status: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RenameSpeakerRequest { + /// Meeting ID + #[prost(string, tag = "1")] + pub meeting_id: ::prost::alloc::string::String, + /// Original speaker ID (e.g., "SPEAKER_00") + #[prost(string, tag = "2")] + pub old_speaker_id: ::prost::alloc::string::String, + /// New speaker name (e.g., "Alice") + #[prost(string, tag = "3")] + pub new_speaker_name: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct RenameSpeakerResponse { + /// Number of segments updated + #[prost(int32, tag = "1")] + pub segments_updated: i32, + /// Success flag + #[prost(bool, tag = "2")] + pub success: bool, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDiarizationJobStatusRequest { + /// Job ID returned by RefineSpeakerDiarization + #[prost(string, tag = "1")] + pub job_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DiarizationJobStatus { + /// Job ID + #[prost(string, tag = "1")] + pub job_id: ::prost::alloc::string::String, + /// Current status + #[prost(enumeration = "JobStatus", tag = "2")] + pub status: i32, + /// Number of segments updated (when completed) + #[prost(int32, tag = "3")] + pub segments_updated: i32, + /// Distinct speaker IDs found (when completed) + #[prost(string, repeated, tag = "4")] + pub speaker_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Error message if failed + #[prost(string, tag = "5")] + pub error_message: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum UpdateType { + Unspecified = 0, + /// Tentative, may change + Partial = 1, + /// Confirmed segment + Final = 2, + /// Voice activity started + VadStart = 3, + /// Voice activity ended + VadEnd = 4, +} +impl UpdateType { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "UPDATE_TYPE_UNSPECIFIED", + Self::Partial => "UPDATE_TYPE_PARTIAL", + Self::Final => "UPDATE_TYPE_FINAL", + Self::VadStart => "UPDATE_TYPE_VAD_START", + Self::VadEnd => "UPDATE_TYPE_VAD_END", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "UPDATE_TYPE_UNSPECIFIED" => Some(Self::Unspecified), + "UPDATE_TYPE_PARTIAL" => Some(Self::Partial), + "UPDATE_TYPE_FINAL" => Some(Self::Final), + "UPDATE_TYPE_VAD_START" => Some(Self::VadStart), + "UPDATE_TYPE_VAD_END" => Some(Self::VadEnd), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum MeetingState { + Unspecified = 0, + /// Created but not started + Created = 1, + /// Actively recording + Recording = 2, + /// Recording stopped, processing may continue + Stopped = 3, + /// All processing complete + Completed = 4, + /// Error occurred + Error = 5, +} +impl MeetingState { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "MEETING_STATE_UNSPECIFIED", + Self::Created => "MEETING_STATE_CREATED", + Self::Recording => "MEETING_STATE_RECORDING", + Self::Stopped => "MEETING_STATE_STOPPED", + Self::Completed => "MEETING_STATE_COMPLETED", + Self::Error => "MEETING_STATE_ERROR", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "MEETING_STATE_UNSPECIFIED" => Some(Self::Unspecified), + "MEETING_STATE_CREATED" => Some(Self::Created), + "MEETING_STATE_RECORDING" => Some(Self::Recording), + "MEETING_STATE_STOPPED" => Some(Self::Stopped), + "MEETING_STATE_COMPLETED" => Some(Self::Completed), + "MEETING_STATE_ERROR" => Some(Self::Error), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum SortOrder { + Unspecified = 0, + /// Newest first (default) + CreatedDesc = 1, + /// Oldest first + CreatedAsc = 2, +} +impl SortOrder { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "SORT_ORDER_UNSPECIFIED", + Self::CreatedDesc => "SORT_ORDER_CREATED_DESC", + Self::CreatedAsc => "SORT_ORDER_CREATED_ASC", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "SORT_ORDER_UNSPECIFIED" => Some(Self::Unspecified), + "SORT_ORDER_CREATED_DESC" => Some(Self::CreatedDesc), + "SORT_ORDER_CREATED_ASC" => Some(Self::CreatedAsc), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum Priority { + Unspecified = 0, + Low = 1, + Medium = 2, + High = 3, +} +impl Priority { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "PRIORITY_UNSPECIFIED", + Self::Low => "PRIORITY_LOW", + Self::Medium => "PRIORITY_MEDIUM", + Self::High => "PRIORITY_HIGH", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PRIORITY_UNSPECIFIED" => Some(Self::Unspecified), + "PRIORITY_LOW" => Some(Self::Low), + "PRIORITY_MEDIUM" => Some(Self::Medium), + "PRIORITY_HIGH" => Some(Self::High), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum AnnotationType { + Unspecified = 0, + ActionItem = 1, + Decision = 2, + Note = 3, + Risk = 4, +} +impl AnnotationType { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "ANNOTATION_TYPE_UNSPECIFIED", + Self::ActionItem => "ANNOTATION_TYPE_ACTION_ITEM", + Self::Decision => "ANNOTATION_TYPE_DECISION", + Self::Note => "ANNOTATION_TYPE_NOTE", + Self::Risk => "ANNOTATION_TYPE_RISK", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "ANNOTATION_TYPE_UNSPECIFIED" => Some(Self::Unspecified), + "ANNOTATION_TYPE_ACTION_ITEM" => Some(Self::ActionItem), + "ANNOTATION_TYPE_DECISION" => Some(Self::Decision), + "ANNOTATION_TYPE_NOTE" => Some(Self::Note), + "ANNOTATION_TYPE_RISK" => Some(Self::Risk), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ExportFormat { + Unspecified = 0, + Markdown = 1, + Html = 2, +} +impl ExportFormat { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "EXPORT_FORMAT_UNSPECIFIED", + Self::Markdown => "EXPORT_FORMAT_MARKDOWN", + Self::Html => "EXPORT_FORMAT_HTML", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "EXPORT_FORMAT_UNSPECIFIED" => Some(Self::Unspecified), + "EXPORT_FORMAT_MARKDOWN" => Some(Self::Markdown), + "EXPORT_FORMAT_HTML" => Some(Self::Html), + _ => None, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum JobStatus { + Unspecified = 0, + Queued = 1, + Running = 2, + Completed = 3, + Failed = 4, +} +impl JobStatus { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "JOB_STATUS_UNSPECIFIED", + Self::Queued => "JOB_STATUS_QUEUED", + Self::Running => "JOB_STATUS_RUNNING", + Self::Completed => "JOB_STATUS_COMPLETED", + Self::Failed => "JOB_STATUS_FAILED", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "JOB_STATUS_UNSPECIFIED" => Some(Self::Unspecified), + "JOB_STATUS_QUEUED" => Some(Self::Queued), + "JOB_STATUS_RUNNING" => Some(Self::Running), + "JOB_STATUS_COMPLETED" => Some(Self::Completed), + "JOB_STATUS_FAILED" => Some(Self::Failed), + _ => None, + } + } +} +/// Generated client implementations. +pub mod note_flow_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct NoteFlowServiceClient { + inner: tonic::client::Grpc, + } + impl NoteFlowServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl NoteFlowServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> NoteFlowServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + NoteFlowServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + /// Bidirectional streaming: client sends audio chunks, server returns transcripts + pub async fn stream_transcription( + &mut self, + request: impl tonic::IntoStreamingRequest, + ) -> std::result::Result< + tonic::Response>, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/StreamTranscription", + ); + let mut req = request.into_streaming_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("noteflow.NoteFlowService", "StreamTranscription"), + ); + self.inner.streaming(req, path, codec).await + } + /// Meeting lifecycle management + pub async fn create_meeting( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/CreateMeeting", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "CreateMeeting")); + self.inner.unary(req, path, codec).await + } + pub async fn stop_meeting( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/StopMeeting", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "StopMeeting")); + self.inner.unary(req, path, codec).await + } + pub async fn list_meetings( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/ListMeetings", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "ListMeetings")); + self.inner.unary(req, path, codec).await + } + pub async fn get_meeting( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/GetMeeting", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "GetMeeting")); + self.inner.unary(req, path, codec).await + } + pub async fn delete_meeting( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/DeleteMeeting", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "DeleteMeeting")); + self.inner.unary(req, path, codec).await + } + /// Summary generation + pub async fn generate_summary( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/GenerateSummary", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "GenerateSummary")); + self.inner.unary(req, path, codec).await + } + /// Annotation management + pub async fn add_annotation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/AddAnnotation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "AddAnnotation")); + self.inner.unary(req, path, codec).await + } + pub async fn get_annotation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/GetAnnotation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "GetAnnotation")); + self.inner.unary(req, path, codec).await + } + pub async fn list_annotations( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/ListAnnotations", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "ListAnnotations")); + self.inner.unary(req, path, codec).await + } + pub async fn update_annotation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/UpdateAnnotation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "UpdateAnnotation")); + self.inner.unary(req, path, codec).await + } + pub async fn delete_annotation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/DeleteAnnotation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "DeleteAnnotation")); + self.inner.unary(req, path, codec).await + } + /// Export functionality + pub async fn export_transcript( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/ExportTranscript", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "ExportTranscript")); + self.inner.unary(req, path, codec).await + } + /// Speaker diarization + pub async fn refine_speaker_diarization( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/RefineSpeakerDiarization", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "noteflow.NoteFlowService", + "RefineSpeakerDiarization", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn rename_speaker( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/RenameSpeaker", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "RenameSpeaker")); + self.inner.unary(req, path, codec).await + } + pub async fn get_diarization_job_status( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/GetDiarizationJobStatus", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "noteflow.NoteFlowService", + "GetDiarizationJobStatus", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Server health and capabilities + pub async fn get_server_info( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/noteflow.NoteFlowService/GetServerInfo", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("noteflow.NoteFlowService", "GetServerInfo")); + self.inner.unary(req, path, codec).await + } + } +} diff --git a/client/src-tauri/src/grpc/types.rs b/client/src-tauri/src/grpc/types.rs index 1a748f8..5393ae3 100644 --- a/client/src-tauri/src/grpc/types.rs +++ b/client/src-tauri/src/grpc/types.rs @@ -59,6 +59,18 @@ impl From<&str> for AnnotationType { } } +impl From for AnnotationType { + fn from(value: i32) -> Self { + match value { + 1 => Self::ActionItem, + 2 => Self::Decision, + 3 => Self::Note, + 4 => Self::Risk, + _ => Self::Unspecified, + } + } +} + /// Export formats (matches proto enum) #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -244,28 +256,6 @@ pub struct AnnotationInfo { pub created_at: f64, } -impl AnnotationInfo { - /// Create a new annotation - pub fn new( - meeting_id: &str, - annotation_type: AnnotationType, - text: &str, - start_time: f64, - end_time: f64, - segment_ids: Option>, - ) -> Self { - Self { - id: new_id(), - meeting_id: meeting_id.to_string(), - annotation_type, - text: text.to_string(), - start_time, - end_time, - segment_ids: segment_ids.unwrap_or_default(), - created_at: now_timestamp(), - } - } -} /// Summary info #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 9d43b0a..bccee47 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ pub mod helpers; pub mod state; pub mod triggers; +use commands::preferences::load_preferences_from_disk; use crypto::CryptoBox; use state::AppState; @@ -31,14 +32,25 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_global_shortcut::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_window_state::Builder::default().build()) .setup(|app| { - // Initialize crypto and state + // Initialize crypto let crypto = Arc::new(CryptoBox::new().map_err(|e| { - tauri::Error::Setup(format!("Failed to initialize crypto: {}", e).into()) + tauri::Error::Setup( + Box::::from(format!( + "Failed to initialize crypto: {}", + e + )) + .into(), + ) })?); - let state = Arc::new(AppState::new(crypto)); + + // Load preferences from disk + let prefs = load_preferences_from_disk(); + + // Create state with loaded preferences + let state = Arc::new(AppState::new_with_preferences(crypto, prefs)); app.manage(state); Ok(()) }) @@ -88,6 +100,9 @@ pub fn run() { commands::audio::list_audio_devices, commands::audio::select_audio_device, commands::audio::get_current_device, + // Preferences + commands::preferences::get_preferences, + commands::preferences::save_preferences, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index 9f1b6aa..f6fad2a 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -2,8 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - if let Err(e) = noteflow_tauri_lib::run() { - eprintln!("Application failed to start: {e}"); - std::process::exit(1); - } + noteflow_tauri_lib::run(); } diff --git a/client/src-tauri/src/state/app_state.rs b/client/src-tauri/src/state/app_state.rs index e21f5e1..89900d3 100644 --- a/client/src-tauri/src/state/app_state.rs +++ b/client/src-tauri/src/state/app_state.rs @@ -11,6 +11,8 @@ use parking_lot::RwLock; use serde::Serialize; use tokio::sync::Mutex; +use crate::audio::PlaybackHandle; +use crate::constants::audio::DEFAULT_SAMPLE_RATE; use crate::crypto::CryptoBox; use crate::grpc::client::GrpcClient; use crate::grpc::types::*; @@ -116,6 +118,9 @@ pub struct AppState { // ========================================================================= // Playback State // ========================================================================= + /// Audio playback handle (channel-based, thread-safe) + pub audio_playback: RwLock>, + /// Playback state machine pub playback_state: RwLock, @@ -125,6 +130,12 @@ pub struct AppState { /// Total playback duration in seconds pub playback_duration: RwLock, + /// Sample rate for current playback buffer + pub playback_sample_rate: RwLock, + + /// Accumulated samples played (for resume tracking) + pub playback_samples_played: RwLock, + /// Session audio buffer (for playback after recording) pub session_audio_buffer: RwLock>, @@ -193,15 +204,29 @@ pub struct AppState { } impl AppState { - /// Create new application state + /// Create new application state with default preferences pub fn new(crypto: Arc) -> Self { - let meetings_dir = directories::ProjectDirs::from("com", "noteflow", "NoteFlow") - .map(|d| d.data_dir().join("meetings")) + Self::new_with_preferences(crypto, HashMap::new()) + } + + /// Create new application state with loaded preferences + pub fn new_with_preferences( + crypto: Arc, + prefs: HashMap, + ) -> Self { + // Get meetings directory from preferences or use default + let meetings_dir = prefs + .get("data_directory") + .and_then(|v| v.as_str()) + .map(|s| PathBuf::from(s).join("meetings")) .unwrap_or_else(|| { - // Fallback to home directory if ProjectDirs fails - directories::BaseDirs::new() - .map(|d| d.home_dir().join(".noteflow").join("meetings")) - .unwrap_or_else(|| PathBuf::from("/tmp/noteflow/meetings")) + directories::ProjectDirs::from("com", "noteflow", "NoteFlow") + .map(|d| d.data_dir().join("meetings")) + .unwrap_or_else(|| { + directories::BaseDirs::new() + .map(|d| d.home_dir().join(".noteflow").join("meetings")) + .unwrap_or_else(|| PathBuf::from("/tmp/noteflow/meetings")) + }) }); Self { @@ -224,9 +249,12 @@ impl AppState { current_partial_text: RwLock::new(String::new()), // Playback + audio_playback: RwLock::new(None), playback_state: RwLock::new(PlaybackState::Stopped), playback_position: RwLock::new(0.0), playback_duration: RwLock::new(0.0), + playback_sample_rate: RwLock::new(DEFAULT_SAMPLE_RATE), + playback_samples_played: RwLock::new(0), session_audio_buffer: RwLock::new(Vec::new()), // Sync @@ -254,8 +282,8 @@ impl AppState { crypto, meetings_dir: RwLock::new(meetings_dir), - // Config - preferences: RwLock::new(HashMap::new()), + // Config - use loaded preferences + preferences: RwLock::new(prefs), } } @@ -278,6 +306,8 @@ impl AppState { self.session_audio_buffer.write().clear(); *self.playback_position.write() = 0.0; *self.playback_duration.write() = 0.0; + *self.playback_sample_rate.write() = DEFAULT_SAMPLE_RATE; + *self.playback_samples_played.write() = 0; *self.playback_state.write() = PlaybackState::Stopped; } diff --git a/client/src-tauri/src/state/state_tests.rs b/client/src-tauri/src/state/state_tests.rs index 743c645..ce50a0a 100644 --- a/client/src-tauri/src/state/state_tests.rs +++ b/client/src-tauri/src/state/state_tests.rs @@ -4,7 +4,9 @@ #[cfg(test)] mod tests { - use crate::state::{PlaybackState, TriggerSource, TriggerAction, TriggerSignal, TriggerDecision}; + use crate::state::{ + PlaybackState, TriggerAction, TriggerDecision, TriggerSignal, TriggerSource, + }; #[test] fn playback_state_equality() { @@ -84,14 +86,12 @@ mod tests { let decision = TriggerDecision { action: TriggerAction::Notify, confidence: 0.85, - signals: vec![ - TriggerSignal { - source: TriggerSource::ForegroundApp, - weight: 0.8, - app_name: Some("Zoom".to_string()), - timestamp: 1234567890.0, - }, - ], + signals: vec![TriggerSignal { + source: TriggerSource::ForegroundApp, + weight: 0.8, + app_name: Some("Zoom".to_string()), + timestamp: 1234567890.0, + }], timestamp: 1234567890.0, detected_app: Some("Zoom".to_string()), }; diff --git a/client/src-tauri/tests/audio_unit_tests.rs b/client/src-tauri/tests/audio_unit_tests.rs new file mode 100644 index 0000000..1b2effb --- /dev/null +++ b/client/src-tauri/tests/audio_unit_tests.rs @@ -0,0 +1,383 @@ +//! Unit tests for audio subsystem edge cases. +//! +//! Run with: cargo test --test audio_unit_tests + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +// ============================================================================= +// Device ID Stability Tests +// ============================================================================= + +/// Replicate the stable_device_id function for testing +fn stable_device_id(name: &str) -> u32 { + let mut hasher = DefaultHasher::new(); + name.hash(&mut hasher); + (hasher.finish() % u32::MAX as u64) as u32 +} + +#[test] +fn test_stable_device_id_same_for_same_name() { + let id1 = stable_device_id("USB Audio Device"); + let id2 = stable_device_id("USB Audio Device"); + + assert_eq!(id1, id2, "Same name should produce same ID"); +} + +#[test] +fn test_stable_device_id_different_for_different_names() { + let id1 = stable_device_id("USB Audio Device"); + let id2 = stable_device_id("Built-in Microphone"); + + assert_ne!(id1, id2, "Different names should produce different IDs"); +} + +#[test] +fn test_stable_device_id_handles_special_characters() { + // Device names can contain special chars + let id1 = stable_device_id("USB (2.0) Device: Main"); + let id2 = stable_device_id("USB (2.0) Device: Main"); + + assert_eq!(id1, id2); +} + +#[test] +fn test_stable_device_id_handles_unicode() { + let id1 = stable_device_id("マイク デバイス"); + let id2 = stable_device_id("マイク デバイス"); + + assert_eq!(id1, id2); +} + +#[test] +fn test_stable_device_id_handles_empty_string() { + let id = stable_device_id(""); + // Should not panic, should return some consistent value + assert_eq!(id, stable_device_id("")); +} + +#[test] +fn test_stable_device_id_consistent_across_calls() { + let name = "Microphone Array (Realtek)"; + let ids: Vec = (0..100).map(|_| stable_device_id(name)).collect(); + + // All IDs should be identical + assert!(ids.iter().all(|&id| id == ids[0])); +} + +// ============================================================================= +// Audio Loader Edge Cases +// ============================================================================= + +#[test] +fn test_samples_to_chunks_basic() { + // Replicate the function logic + fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> { + let chunk_duration = 0.1; + let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1); + let mut chunks = Vec::new(); + let mut offset = 0; + + while offset < samples.len() { + let end = (offset + chunk_samples).min(samples.len()); + let frame_count = end - offset; + let duration = frame_count as f64 / sample_rate as f64; + let timestamp = offset as f64 / sample_rate as f64; + chunks.push((timestamp, duration, frame_count)); + offset = end; + } + chunks + } + + let samples: Vec = vec![0.0; 48000]; // 1 second at 48kHz + let chunks = samples_to_chunks(&samples, 48000); + + // Should have ~10 chunks of 0.1s each + assert_eq!(chunks.len(), 10); + assert!((chunks[0].1 - 0.1).abs() < 0.001); +} + +#[test] +fn test_samples_to_chunks_odd_sample_count() { + fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> { + let chunk_duration = 0.1; + let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1); + let mut chunks = Vec::new(); + let mut offset = 0; + + while offset < samples.len() { + let end = (offset + chunk_samples).min(samples.len()); + let frame_count = end - offset; + let duration = frame_count as f64 / sample_rate as f64; + let timestamp = offset as f64 / sample_rate as f64; + chunks.push((timestamp, duration, frame_count)); + offset = end; + } + chunks + } + + // Odd number of samples that doesn't divide evenly + let samples: Vec = vec![0.0; 4801]; + let chunks = samples_to_chunks(&samples, 48000); + + // Last chunk should be smaller + let last = chunks.last().unwrap(); + assert!(last.2 < 4800); // Less than a full chunk +} + +#[test] +fn test_samples_to_chunks_low_sample_rate() { + fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> { + let chunk_duration = 0.1; + let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1); + let mut chunks = Vec::new(); + let mut offset = 0; + + while offset < samples.len() { + let end = (offset + chunk_samples).min(samples.len()); + let frame_count = end - offset; + let duration = frame_count as f64 / sample_rate as f64; + let timestamp = offset as f64 / sample_rate as f64; + chunks.push((timestamp, duration, frame_count)); + offset = end; + } + chunks + } + + // Very low sample rate + let samples: Vec = vec![0.0; 100]; + let chunks = samples_to_chunks(&samples, 100); // 100 Hz + + // At 100Hz, 0.1s = 10 samples per chunk, so 100 samples = 10 chunks + assert_eq!(chunks.len(), 10); +} + +#[test] +fn test_samples_to_chunks_minimum_chunk_size() { + fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> { + let chunk_duration = 0.1; + let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1); + let mut chunks = Vec::new(); + let mut offset = 0; + + while offset < samples.len() { + let end = (offset + chunk_samples).min(samples.len()); + let frame_count = end - offset; + let duration = frame_count as f64 / sample_rate as f64; + let timestamp = offset as f64 / sample_rate as f64; + chunks.push((timestamp, duration, frame_count)); + offset = end; + } + chunks + } + + // Sample rate so low that 0.1s < 1 sample - should clamp to 1 + let samples: Vec = vec![0.0; 5]; + let chunks = samples_to_chunks(&samples, 5); // 5 Hz → 0.5 samples/chunk → clamped to 1 + + // With .max(1), each chunk has at least 1 sample + assert_eq!(chunks.len(), 5); + for chunk in &chunks { + assert!(chunk.2 >= 1, "Chunk should have at least 1 sample"); + } +} + +#[test] +fn test_samples_to_chunks_empty_input() { + fn samples_to_chunks(samples: &[f32], sample_rate: u32) -> Vec<(f64, f64, usize)> { + let chunk_duration = 0.1; + let chunk_samples = ((sample_rate as f64 * chunk_duration) as usize).max(1); + let mut chunks = Vec::new(); + let mut offset = 0; + + while offset < samples.len() { + let end = (offset + chunk_samples).min(samples.len()); + let frame_count = end - offset; + let duration = frame_count as f64 / sample_rate as f64; + let timestamp = offset as f64 / sample_rate as f64; + chunks.push((timestamp, duration, frame_count)); + offset = end; + } + chunks + } + + let samples: Vec = vec![]; + let chunks = samples_to_chunks(&samples, 48000); + + assert!(chunks.is_empty()); +} + +// ============================================================================= +// Sample Rate Validation Tests +// ============================================================================= + +#[test] +fn test_sample_rate_zero_rejected() { + // This tests the validation logic: sample_rate == 0 should be rejected + fn validate_sample_rate(sample_rate: u32) -> Result<(), &'static str> { + if sample_rate == 0 { + return Err("Invalid sample rate: 0"); + } + Ok(()) + } + + assert!(validate_sample_rate(0).is_err()); + assert!(validate_sample_rate(16000).is_ok()); + assert!(validate_sample_rate(48000).is_ok()); +} + +#[test] +fn test_duration_calculation_various_rates() { + fn calculate_duration(num_samples: usize, sample_rate: u32) -> f64 { + if sample_rate == 0 { + return 0.0; + } + num_samples as f64 / sample_rate as f64 + } + + // 16kHz: 16000 samples = 1 second + assert!((calculate_duration(16000, 16000) - 1.0).abs() < 0.001); + + // 48kHz: 48000 samples = 1 second + assert!((calculate_duration(48000, 48000) - 1.0).abs() < 0.001); + + // 44.1kHz: 44100 samples = 1 second + assert!((calculate_duration(44100, 44100) - 1.0).abs() < 0.001); + + // Edge case: 0 sample rate + assert_eq!(calculate_duration(1000, 0), 0.0); +} + +// ============================================================================= +// Playback Position Edge Cases +// ============================================================================= + +#[test] +fn test_seek_position_clamping() { + fn clamp_position(pos: f64, duration: f64) -> f64 { + pos.clamp(0.0, duration.max(0.0)) + } + + // Normal case + assert!((clamp_position(50.0, 100.0) - 50.0).abs() < 0.001); + + // Beyond duration + assert!((clamp_position(150.0, 100.0) - 100.0).abs() < 0.001); + + // Negative + assert!((clamp_position(-10.0, 100.0) - 0.0).abs() < 0.001); + + // Zero duration + assert!((clamp_position(50.0, 0.0) - 0.0).abs() < 0.001); + + // Negative duration (edge case) + assert!((clamp_position(50.0, -10.0) - 0.0).abs() < 0.001); +} + +#[test] +fn test_position_tracking_accumulation() { + // Simulate position tracker accumulating samples + let sample_rate: u32 = 48000; + let samples_per_update: u64 = 4800; // 0.1 second chunks + + let mut samples_played: u64 = 0; + + // Simulate 10 updates + for _ in 0..10 { + samples_played += samples_per_update; + } + + let position = samples_played as f64 / sample_rate as f64; + assert!((position - 1.0).abs() < 0.001, "Should be ~1 second"); +} + +#[test] +fn test_position_tracking_pause_resume() { + // Simulate pause/resume preserving position + let sample_rate: u32 = 48000; + let mut samples_played: u64 = 0; + + // Play for 0.5 seconds + samples_played += 24000; + + // Pause - samples_played is preserved + let paused_samples = samples_played; + + // Resume - should continue from paused position + samples_played = paused_samples; + samples_played += 24000; + + let position = samples_played as f64 / sample_rate as f64; + assert!((position - 1.0).abs() < 0.001, "Should be 1 second total"); +} + +// ============================================================================= +// Segment Finding Edge Cases +// ============================================================================= + +#[derive(Debug, Clone)] +struct MockSegment { + id: i32, + start_time: f64, + end_time: f64, +} + +fn find_segment_at_position(segments: &[MockSegment], position: f64) -> Option { + for seg in segments { + if position >= seg.start_time && position < seg.end_time { + return Some(seg.id); + } + } + None +} + +#[test] +fn test_find_segment_in_gap() { + let segments = vec![ + MockSegment { id: 1, start_time: 0.0, end_time: 5.0 }, + MockSegment { id: 2, start_time: 10.0, end_time: 15.0 }, + ]; + + // Position in gap between segments + let result = find_segment_at_position(&segments, 7.5); + assert!(result.is_none(), "Should return None for gap"); +} + +#[test] +fn test_find_segment_at_boundary() { + let segments = vec![ + MockSegment { id: 1, start_time: 0.0, end_time: 5.0 }, + MockSegment { id: 2, start_time: 5.0, end_time: 10.0 }, + ]; + + // Exactly at segment boundary + let result = find_segment_at_position(&segments, 5.0); + assert_eq!(result, Some(2), "Should be in second segment"); +} + +#[test] +fn test_find_segment_before_all() { + let segments = vec![ + MockSegment { id: 1, start_time: 5.0, end_time: 10.0 }, + ]; + + let result = find_segment_at_position(&segments, 2.0); + assert!(result.is_none(), "Should return None before first segment"); +} + +#[test] +fn test_find_segment_after_all() { + let segments = vec![ + MockSegment { id: 1, start_time: 0.0, end_time: 5.0 }, + ]; + + let result = find_segment_at_position(&segments, 10.0); + assert!(result.is_none(), "Should return None after last segment"); +} + +#[test] +fn test_find_segment_empty_list() { + let segments: Vec = vec![]; + let result = find_segment_at_position(&segments, 5.0); + assert!(result.is_none()); +} diff --git a/client/src-tauri/tests/grpc_integration.rs b/client/src-tauri/tests/grpc_integration.rs new file mode 100644 index 0000000..9366fe5 --- /dev/null +++ b/client/src-tauri/tests/grpc_integration.rs @@ -0,0 +1,326 @@ +//! Integration tests for gRPC client against live server. +//! +//! These tests require the NoteFlow server running at localhost:50051. +//! Run with: cargo test --test grpc_integration -- --test-threads=1 + +use std::time::Duration; +use tokio::time::timeout; + +// Import the library +use noteflow_tauri_lib::grpc::{GrpcClient, AnnotationType}; + +const SERVER_ADDR: &str = "localhost:50051"; +const TEST_TIMEOUT: Duration = Duration::from_secs(10); + +/// Helper to create a connected client or skip test if server unavailable +async fn connected_client() -> Option { + let client = GrpcClient::new(); + match timeout(Duration::from_secs(2), client.connect(SERVER_ADDR)).await { + Ok(Ok(_)) => Some(client), + Ok(Err(e)) => { + eprintln!("Server unavailable: {e} - skipping integration test"); + None + } + Err(_) => { + eprintln!("Connection timeout - skipping integration test"); + None + } + } +} + +// ============================================================================= +// Connection Tests +// ============================================================================= + +#[tokio::test] +async fn test_connect_to_server() { + let client = GrpcClient::new(); + + let result = timeout(TEST_TIMEOUT, client.connect(SERVER_ADDR)).await; + + match result { + Ok(Ok(info)) => { + assert!(client.is_connected()); + assert!(!info.version.is_empty(), "Server should return version"); + } + Ok(Err(e)) => { + eprintln!("Server not available: {e}"); + } + Err(_) => { + panic!("Connection timed out"); + } + } +} + +#[tokio::test] +async fn test_disconnect_clears_state() { + let Some(client) = connected_client().await else { return }; + + assert!(client.is_connected()); + + client.disconnect().await; + + assert!(!client.is_connected()); + assert!(client.server_address().is_empty()); +} + +#[tokio::test] +async fn test_operations_fail_when_disconnected() { + let client = GrpcClient::new(); + + // Should fail without connection + let result = client.list_annotations("meeting-123", 0.0, 100.0).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Not connected")); +} + +// ============================================================================= +// Annotation CRUD Tests +// ============================================================================= + +#[tokio::test] +async fn test_annotation_add_and_get() { + let Some(client) = connected_client().await else { return }; + + // First create a meeting to attach annotation to + let meeting = client.create_meeting("Test Meeting for Annotations", None).await; + let Ok(meeting) = meeting else { + eprintln!("Could not create meeting - skipping"); + return; + }; + + // Add annotation + let result = client.add_annotation( + &meeting.id, + AnnotationType::ActionItem, + "Follow up with team", + 10.0, + 15.0, + Some(vec![1, 2]), + ).await; + + match result { + Ok(annotation) => { + assert!(!annotation.id.is_empty()); + assert_eq!(annotation.meeting_id, meeting.id); + assert_eq!(annotation.text, "Follow up with team"); + assert!((annotation.start_time - 10.0).abs() < 0.001); + assert!((annotation.end_time - 15.0).abs() < 0.001); + + // Get the annotation back + let fetched = client.get_annotation(&annotation.id).await; + match fetched { + Ok(a) => { + assert_eq!(a.id, annotation.id); + assert_eq!(a.text, annotation.text); + } + Err(e) => eprintln!("Get annotation failed: {e}"), + } + } + Err(e) => { + eprintln!("Add annotation failed (server may not support): {e}"); + } + } + + // Cleanup + let _ = client.delete_meeting(&meeting.id).await; +} + +#[tokio::test] +async fn test_annotation_list_filters_by_time() { + let Some(client) = connected_client().await else { return }; + + // Create meeting + let meeting = client.create_meeting("List Filter Test", None).await; + let Ok(meeting) = meeting else { return }; + + // Add annotations at different times + let _ = client.add_annotation(&meeting.id, AnnotationType::Note, "Early note", 5.0, 10.0, None).await; + let _ = client.add_annotation(&meeting.id, AnnotationType::Note, "Middle note", 50.0, 55.0, None).await; + let _ = client.add_annotation(&meeting.id, AnnotationType::Note, "Late note", 100.0, 105.0, None).await; + + // List only middle range + let result = client.list_annotations(&meeting.id, 40.0, 60.0).await; + + match result { + Ok(annotations) => { + // Should only include annotations overlapping 40-60s range + for ann in &annotations { + assert!( + ann.end_time >= 40.0 && ann.start_time <= 60.0, + "Annotation outside time range: {}-{}", + ann.start_time, + ann.end_time + ); + } + } + Err(e) => eprintln!("List annotations failed: {e}"), + } + + // Cleanup + let _ = client.delete_meeting(&meeting.id).await; +} + +#[tokio::test] +async fn test_annotation_update() { + let Some(client) = connected_client().await else { return }; + + let meeting = client.create_meeting("Update Test", None).await; + let Ok(meeting) = meeting else { return }; + + // Add annotation + let added = client.add_annotation( + &meeting.id, + AnnotationType::Note, + "Original text", + 0.0, + 10.0, + None, + ).await; + + let Ok(annotation) = added else { return }; + + // Update it + let updated = client.update_annotation( + &annotation.id, + Some(AnnotationType::Decision), + Some("Updated text"), + None, + None, + None, + ).await; + + match updated { + Ok(a) => { + assert_eq!(a.text, "Updated text"); + // Type should be updated + assert_eq!(a.annotation_type, AnnotationType::Decision); + } + Err(e) => eprintln!("Update annotation failed: {e}"), + } + + // Cleanup + let _ = client.delete_meeting(&meeting.id).await; +} + +#[tokio::test] +async fn test_annotation_delete() { + let Some(client) = connected_client().await else { return }; + + let meeting = client.create_meeting("Delete Test", None).await; + let Ok(meeting) = meeting else { return }; + + // Add annotation + let added = client.add_annotation( + &meeting.id, + AnnotationType::Risk, + "To be deleted", + 0.0, + 5.0, + None, + ).await; + + let Ok(annotation) = added else { return }; + + // Delete it + let result = client.delete_annotation(&annotation.id).await; + + match result { + Ok(success) => { + assert!(success); + + // Verify it's gone + let fetch = client.get_annotation(&annotation.id).await; + assert!(fetch.is_err(), "Deleted annotation should not be fetchable"); + } + Err(e) => eprintln!("Delete annotation failed: {e}"), + } + + // Cleanup + let _ = client.delete_meeting(&meeting.id).await; +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +#[tokio::test] +async fn test_annotation_with_empty_text() { + let Some(client) = connected_client().await else { return }; + + let meeting = client.create_meeting("Empty Text Test", None).await; + let Ok(meeting) = meeting else { return }; + + // Empty text should be allowed or rejected gracefully + let result = client.add_annotation( + &meeting.id, + AnnotationType::Note, + "", // Empty text + 0.0, + 5.0, + None, + ).await; + + // Either succeeds with empty or returns proper error - shouldn't panic + match result { + Ok(ann) => assert!(ann.text.is_empty()), + Err(e) => assert!(!e.to_string().is_empty()), + } + + let _ = client.delete_meeting(&meeting.id).await; +} + +#[tokio::test] +async fn test_annotation_with_negative_times() { + let Some(client) = connected_client().await else { return }; + + let meeting = client.create_meeting("Negative Time Test", None).await; + let Ok(meeting) = meeting else { return }; + + // Negative times - should be rejected or handled gracefully + let result = client.add_annotation( + &meeting.id, + AnnotationType::Note, + "Negative time test", + -5.0, // Negative start + -2.0, // Negative end + None, + ).await; + + // Should either reject or clamp - shouldn't panic + match result { + Ok(_) => { /* Server accepted it - valid behavior */ } + Err(e) => assert!(!e.to_string().is_empty()), + } + + let _ = client.delete_meeting(&meeting.id).await; +} + +#[tokio::test] +async fn test_annotation_on_nonexistent_meeting() { + let Some(client) = connected_client().await else { return }; + + let result = client.add_annotation( + "nonexistent-meeting-id-12345", + AnnotationType::Note, + "Should fail", + 0.0, + 5.0, + None, + ).await; + + // Should return error, not panic + assert!(result.is_err(), "Should fail for nonexistent meeting"); +} + +#[tokio::test] +async fn test_get_nonexistent_annotation() { + let Some(client) = connected_client().await else { return }; + + let result = client.get_annotation("nonexistent-annotation-id-12345").await; + + // Should return error, not panic + assert!(result.is_err(), "Should fail for nonexistent annotation"); +} diff --git a/client/src-tauri/tests/preferences_tests.rs b/client/src-tauri/tests/preferences_tests.rs new file mode 100644 index 0000000..2aac4b0 --- /dev/null +++ b/client/src-tauri/tests/preferences_tests.rs @@ -0,0 +1,280 @@ +//! Tests for preferences persistence and security. +//! +//! Run with: cargo test --test preferences_tests + +use std::collections::HashMap; +use std::path::PathBuf; +use tempfile::TempDir; + +// ============================================================================= +// Preferences File Persistence Tests +// ============================================================================= + +#[test] +fn test_preferences_round_trip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("preferences.json"); + + let mut prefs: HashMap = HashMap::new(); + prefs.insert("server_url".to_string(), serde_json::json!("localhost:50051")); + prefs.insert("data_directory".to_string(), serde_json::json!("/data/noteflow")); + prefs.insert("auto_connect".to_string(), serde_json::json!(true)); + + // Write + let json = serde_json::to_string_pretty(&prefs).unwrap(); + std::fs::write(&path, &json).unwrap(); + + // Read back + let read_json = std::fs::read_to_string(&path).unwrap(); + let loaded: HashMap = serde_json::from_str(&read_json).unwrap(); + + assert_eq!(loaded.get("server_url"), prefs.get("server_url")); + assert_eq!(loaded.get("data_directory"), prefs.get("data_directory")); + assert_eq!(loaded.get("auto_connect"), prefs.get("auto_connect")); +} + +#[test] +fn test_preferences_empty_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("preferences.json"); + + std::fs::write(&path, "{}").unwrap(); + + let json = std::fs::read_to_string(&path).unwrap(); + let loaded: HashMap = serde_json::from_str(&json).unwrap(); + + assert!(loaded.is_empty()); +} + +#[test] +fn test_preferences_missing_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.json"); + + // Should handle gracefully + let result = std::fs::read_to_string(&path); + assert!(result.is_err()); + + // Default behavior: return empty HashMap + let prefs: HashMap = if path.exists() { + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap_or_default() + } else { + HashMap::new() + }; + + assert!(prefs.is_empty()); +} + +#[test] +fn test_preferences_corrupt_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("corrupt.json"); + + // Write invalid JSON + std::fs::write(&path, "{ invalid json ").unwrap(); + + let json = std::fs::read_to_string(&path).unwrap(); + let result: Result, _> = serde_json::from_str(&json); + + assert!(result.is_err(), "Should fail to parse corrupt JSON"); + + // Fallback should be empty + let prefs = result.unwrap_or_default(); + assert!(prefs.is_empty()); +} + +#[test] +fn test_preferences_creates_parent_directory() { + let dir = TempDir::new().unwrap(); + let nested_path = dir.path().join("nested").join("deep").join("preferences.json"); + + // Parent doesn't exist yet + assert!(!nested_path.parent().unwrap().exists()); + + // Create parent and write + if let Some(parent) = nested_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&nested_path, "{}").unwrap(); + + assert!(nested_path.exists()); +} + +// ============================================================================= +// API Key Masking Tests +// ============================================================================= + +fn mask_api_key(key: &str) -> String { + if key.len() <= 8 { + "*".repeat(key.len()) + } else { + format!("{}...{}", &key[..4], &key[key.len()-4..]) + } +} + +#[test] +fn test_api_key_masking_long_key() { + let key = "sk-1234567890abcdef"; + let masked = mask_api_key(key); + + assert_eq!(masked, "sk-1...cdef"); + assert!(!masked.contains("567890")); +} + +#[test] +fn test_api_key_masking_short_key() { + let key = "abc123"; + let masked = mask_api_key(key); + + assert_eq!(masked, "******"); +} + +#[test] +fn test_api_key_masking_empty() { + let key = ""; + let masked = mask_api_key(key); + + assert_eq!(masked, ""); +} + +#[test] +fn test_api_key_masking_boundary() { + // Exactly 8 chars + let key = "12345678"; + let masked = mask_api_key(key); + + assert_eq!(masked, "********"); +} + +#[test] +fn test_api_key_masking_9_chars() { + // 9 chars - should show first 4 and last 4 + let key = "123456789"; + let masked = mask_api_key(key); + + assert_eq!(masked, "1234...6789"); +} + +fn is_masked_key(key: &str) -> bool { + key.contains("...") || key.chars().all(|c| c == '*') +} + +#[test] +fn test_detect_masked_key() { + assert!(is_masked_key("sk-1...cdef")); + assert!(is_masked_key("******")); + assert!(is_masked_key("****")); + assert!(!is_masked_key("sk-1234567890")); + assert!(!is_masked_key("real-api-key")); +} + +// ============================================================================= +// Preferences Update Logic Tests +// ============================================================================= + +#[test] +fn test_preferences_merge_update() { + let mut existing: HashMap = HashMap::new(); + existing.insert("server_url".to_string(), serde_json::json!("old.server:50051")); + existing.insert("keep_this".to_string(), serde_json::json!("preserved")); + + // New preferences to merge + let updates: HashMap = [ + ("server_url".to_string(), serde_json::json!("new.server:50051")), + ("new_key".to_string(), serde_json::json!("added")), + ].into_iter().collect(); + + // Merge + for (key, value) in updates { + existing.insert(key, value); + } + + assert_eq!(existing.get("server_url"), Some(&serde_json::json!("new.server:50051"))); + assert_eq!(existing.get("keep_this"), Some(&serde_json::json!("preserved"))); + assert_eq!(existing.get("new_key"), Some(&serde_json::json!("added"))); +} + +#[test] +fn test_preferences_exclude_api_key_from_file() { + let mut prefs: HashMap = HashMap::new(); + prefs.insert("server_url".to_string(), serde_json::json!("localhost:50051")); + prefs.insert("cloud_api_key".to_string(), serde_json::json!("secret-key")); + + // Before persisting, remove API key + prefs.remove("cloud_api_key"); + + let json = serde_json::to_string(&prefs).unwrap(); + + assert!(!json.contains("secret-key")); + assert!(!json.contains("cloud_api_key")); + assert!(json.contains("server_url")); +} + +// ============================================================================= +// Config Directory Tests +// ============================================================================= + +#[test] +fn test_config_directory_resolution() { + // Test the fallback logic for config directory + fn get_config_dir() -> PathBuf { + directories::ProjectDirs::from("com", "noteflow", "NoteFlow") + .map(|d| d.config_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from("/tmp/noteflow")) + } + + let dir = get_config_dir(); + + // Should be a valid path (not empty) + assert!(!dir.as_os_str().is_empty()); + + // Should contain "noteflow" somewhere in the path + let path_str = dir.to_string_lossy().to_lowercase(); + assert!(path_str.contains("noteflow"), "Config dir should contain 'noteflow': {}", path_str); +} + +// ============================================================================= +// Selected Device Persistence Tests +// ============================================================================= + +#[test] +fn test_device_selection_persistence_format() { + // Device selection should store both ID and name for robustness + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] + struct DeviceSelection { + id: u32, + name: String, + } + + let selection = DeviceSelection { + id: 123456789, + name: "USB Microphone".to_string(), + }; + + let json = serde_json::to_string(&selection).unwrap(); + let loaded: DeviceSelection = serde_json::from_str(&json).unwrap(); + + assert_eq!(loaded, selection); +} + +#[test] +fn test_device_selection_lookup_by_name() { + // When device IDs change, lookup by name should work + #[derive(Debug)] + struct Device { + id: u32, + name: String, + } + + let devices = vec![ + Device { id: 111, name: "Built-in Mic".to_string() }, + Device { id: 222, name: "USB Microphone".to_string() }, + ]; + + // Stored preference has old ID but valid name + let stored_name = "USB Microphone"; + + let found = devices.iter().find(|d| d.name == stored_name); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, 222); +} diff --git a/client/src/App.tsx b/client/src/App.tsx index b2ffa45..1ca8a09 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,13 +1,14 @@ import { useEffect } from 'react'; -import { useStore } from '@/store'; +import { ConnectionPanel } from '@/components/connection/ConnectionPanel'; +import { MeetingLibrary } from '@/components/meetings/MeetingLibrary'; +import { RecordingPanel } from '@/components/recording/RecordingPanel'; +import { SettingsPanel } from '@/components/settings'; +import { SummaryPanel } from '@/components/summary/SummaryPanel'; +import { TranscriptView } from '@/components/transcript/TranscriptView'; +import { TriggerDialog } from '@/components/triggers/TriggerDialog'; import { useTauriEvents } from '@/hooks/useTauriEvents'; import { api } from '@/lib/tauri'; -import { ConnectionPanel } from '@/components/connection/ConnectionPanel'; -import { RecordingPanel } from '@/components/recording/RecordingPanel'; -import { TranscriptView } from '@/components/transcript/TranscriptView'; -import { SummaryPanel } from '@/components/summary/SummaryPanel'; -import { MeetingLibrary } from '@/components/meetings/MeetingLibrary'; -import { TriggerDialog } from '@/components/triggers/TriggerDialog'; +import { useStore } from '@/store'; export function App() { const { viewMode, setViewMode, serverAddress, setConnectionState } = useStore(); @@ -69,7 +70,7 @@ export function App() {
{viewMode === 'recording' && } {viewMode === 'library' && } - {viewMode === 'settings' && } + {viewMode === 'settings' && }
{/* Trigger Dialog (modal) */} @@ -122,11 +123,3 @@ function RecordingView() { ); } -function SettingsView() { - return ( -
-

Settings

-

Settings panel coming soon...

-
- ); -} diff --git a/client/src/components/annotations/AnnotationBadge.tsx b/client/src/components/annotations/AnnotationBadge.tsx new file mode 100644 index 0000000..990dadc --- /dev/null +++ b/client/src/components/annotations/AnnotationBadge.tsx @@ -0,0 +1,61 @@ +import { AlertCircle, AlertTriangle, CheckSquare, StickyNote, X } from 'lucide-react'; +import type { AnnotationType } from '@/types/annotation'; + +interface AnnotationBadgeProps { + type: AnnotationType; + text: string; + onRemove?: () => void; +} + +const ANNOTATION_CONFIG: Record< + AnnotationType, + { icon: typeof CheckSquare; bgColor: string; textColor: string; label: string } +> = { + action_item: { + icon: CheckSquare, + bgColor: 'bg-green-900/30', + textColor: 'text-green-400', + label: 'Action', + }, + decision: { + icon: AlertCircle, + bgColor: 'bg-purple-900/30', + textColor: 'text-purple-400', + label: 'Decision', + }, + note: { + icon: StickyNote, + bgColor: 'bg-blue-900/30', + textColor: 'text-blue-400', + label: 'Note', + }, + risk: { + icon: AlertTriangle, + bgColor: 'bg-red-900/30', + textColor: 'text-red-400', + label: 'Risk', + }, +}; + +export function AnnotationBadge({ type, text, onRemove }: AnnotationBadgeProps) { + const config = ANNOTATION_CONFIG[type]; + const Icon = config.icon; + + return ( +
+ + {text || config.label} + {onRemove && ( + + )} +
+ ); +} diff --git a/client/src/components/annotations/AnnotationList.tsx b/client/src/components/annotations/AnnotationList.tsx new file mode 100644 index 0000000..e2ff9d3 --- /dev/null +++ b/client/src/components/annotations/AnnotationList.tsx @@ -0,0 +1,26 @@ +import type { AnnotationInfo } from '@/types/annotation'; +import { AnnotationBadge } from './AnnotationBadge'; + +interface AnnotationListProps { + annotations: AnnotationInfo[]; + onRemove?: (annotationId: string) => void; +} + +export function AnnotationList({ annotations, onRemove }: AnnotationListProps) { + if (annotations.length === 0) { + return null; + } + + return ( +
+ {annotations.map((annotation) => ( + onRemove(annotation.id) : undefined} + /> + ))} +
+ ); +} diff --git a/client/src/components/annotations/AnnotationToolbar.test.tsx b/client/src/components/annotations/AnnotationToolbar.test.tsx new file mode 100644 index 0000000..6ad1155 --- /dev/null +++ b/client/src/components/annotations/AnnotationToolbar.test.tsx @@ -0,0 +1,374 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { api } from '@/lib/tauri'; +import { AnnotationToolbar } from './AnnotationToolbar'; + +// Mock the store +vi.mock('@/store', () => ({ + useStore: vi.fn(() => ({ + addAnnotation: vi.fn(), + })), +})); + +// Mock the tauri API +vi.mock('@/lib/tauri', () => ({ + api: { + annotations: { + add: vi.fn(), + }, + }, +})); + +import { useStore } from '@/store'; + +const mockUseStore = vi.mocked(useStore); +const mockAddAnnotation = vi.fn(); + +const defaultProps = { + meetingId: 'meeting-123', + segmentId: 1, + startTime: 10.0, + endTime: 15.0, +}; + +describe('AnnotationToolbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseStore.mockReturnValue({ + addAnnotation: mockAddAnnotation, + }); + }); + + describe('rendering', () => { + it('should render all annotation type buttons', () => { + render(); + + expect(screen.getByTitle('Action')).toBeInTheDocument(); + expect(screen.getByTitle('Decision')).toBeInTheDocument(); + expect(screen.getByTitle('Note')).toBeInTheDocument(); + expect(screen.getByTitle('Risk')).toBeInTheDocument(); + }); + + it('should not show note input initially', () => { + render(); + + expect(screen.queryByPlaceholderText('Add note...')).not.toBeInTheDocument(); + }); + }); + + describe('quick annotations (action, decision, risk)', () => { + it('should add action item annotation', async () => { + const mockAnnotation = { + id: 'ann-1', + meeting_id: 'meeting-123', + annotation_type: 'action_item', + text: '', + start_time: 10.0, + end_time: 15.0, + segment_ids: [1], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render(); + const actionButton = screen.getByTitle('Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(api.annotations.add).toHaveBeenCalledWith( + 'meeting-123', + 'action_item', + '', + 10.0, + 15.0, + [1] + ); + }); + + await waitFor(() => { + expect(mockAddAnnotation).toHaveBeenCalledWith(mockAnnotation); + }); + }); + + it('should add decision annotation', async () => { + const mockAnnotation = { + id: 'ann-2', + meeting_id: 'meeting-123', + annotation_type: 'decision', + text: '', + start_time: 10.0, + end_time: 15.0, + segment_ids: [1], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render(); + const decisionButton = screen.getByTitle('Decision'); + fireEvent.click(decisionButton); + + await waitFor(() => { + expect(api.annotations.add).toHaveBeenCalledWith( + 'meeting-123', + 'decision', + '', + 10.0, + 15.0, + [1] + ); + }); + }); + + it('should add risk annotation', async () => { + const mockAnnotation = { + id: 'ann-3', + meeting_id: 'meeting-123', + annotation_type: 'risk', + text: '', + start_time: 10.0, + end_time: 15.0, + segment_ids: [1], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render(); + const riskButton = screen.getByTitle('Risk'); + fireEvent.click(riskButton); + + await waitFor(() => { + expect(api.annotations.add).toHaveBeenCalledWith( + 'meeting-123', + 'risk', + '', + 10.0, + 15.0, + [1] + ); + }); + }); + }); + + describe('note annotation with text input', () => { + it('should show text input when clicking Note button', async () => { + render(); + + const noteButton = screen.getByTitle('Note'); + fireEvent.click(noteButton); + + expect(screen.getByPlaceholderText('Add note...')).toBeInTheDocument(); + }); + + it('should submit note on Enter key', async () => { + const user = userEvent.setup(); + const mockAnnotation = { + id: 'ann-4', + meeting_id: 'meeting-123', + annotation_type: 'note', + text: 'Important note', + start_time: 10.0, + end_time: 15.0, + segment_ids: [1], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render(); + + // Click Note to show input + const noteButton = screen.getByTitle('Note'); + fireEvent.click(noteButton); + + // Type and submit + const input = screen.getByPlaceholderText('Add note...'); + await user.type(input, 'Important note{enter}'); + + await waitFor(() => { + expect(api.annotations.add).toHaveBeenCalledWith( + 'meeting-123', + 'note', + 'Important note', + 10.0, + 15.0, + [1] + ); + }); + }); + + it('should close input on Escape key', async () => { + const user = userEvent.setup(); + render(); + + // Click Note to show input + const noteButton = screen.getByTitle('Note'); + fireEvent.click(noteButton); + + const input = screen.getByPlaceholderText('Add note...'); + await user.type(input, 'Some text{escape}'); + + expect(screen.queryByPlaceholderText('Add note...')).not.toBeInTheDocument(); + }); + + it('should not submit empty note', async () => { + const user = userEvent.setup(); + render(); + + // Click Note to show input + const noteButton = screen.getByTitle('Note'); + fireEvent.click(noteButton); + + // Try to submit empty + const input = screen.getByPlaceholderText('Add note...'); + await user.type(input, '{enter}'); + + expect(api.annotations.add).not.toHaveBeenCalled(); + }); + + it('should trim whitespace from note text', async () => { + const user = userEvent.setup(); + const mockAnnotation = { + id: 'ann-5', + meeting_id: 'meeting-123', + annotation_type: 'note', + text: 'Trimmed note', + start_time: 10.0, + end_time: 15.0, + segment_ids: [1], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render(); + + const noteButton = screen.getByTitle('Note'); + fireEvent.click(noteButton); + + const input = screen.getByPlaceholderText('Add note...'); + await user.type(input, ' Trimmed note {enter}'); + + await waitFor(() => { + expect(api.annotations.add).toHaveBeenCalledWith( + 'meeting-123', + 'note', + 'Trimmed note', + 10.0, + 15.0, + [1] + ); + }); + }); + + it('should close input after successful submission', async () => { + const user = userEvent.setup(); + const mockAnnotation = { + id: 'ann-6', + meeting_id: 'meeting-123', + annotation_type: 'note', + text: 'Test note', + start_time: 10.0, + end_time: 15.0, + segment_ids: [1], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render(); + + const noteButton = screen.getByTitle('Note'); + fireEvent.click(noteButton); + + const input = screen.getByPlaceholderText('Add note...'); + await user.type(input, 'Test note{enter}'); + + await waitFor(() => { + expect(screen.queryByPlaceholderText('Add note...')).not.toBeInTheDocument(); + }); + }); + }); + + describe('loading state', () => { + it('should disable buttons while loading', async () => { + // Make add hang to simulate loading + vi.mocked(api.annotations.add).mockImplementation( + () => new Promise(() => {}) // Never resolves + ); + + render(); + const actionButton = screen.getByTitle('Action'); + fireEvent.click(actionButton); + + // All buttons should be disabled + await waitFor(() => { + expect(screen.getByTitle('Action')).toBeDisabled(); + expect(screen.getByTitle('Decision')).toBeDisabled(); + expect(screen.getByTitle('Note')).toBeDisabled(); + expect(screen.getByTitle('Risk')).toBeDisabled(); + }); + }); + }); + + describe('error handling', () => { + it('should handle API error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(api.annotations.add).mockRejectedValue(new Error('Server error')); + + render(); + const actionButton = screen.getByTitle('Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to add annotation:', + expect.any(Error) + ); + }); + + // Buttons should be re-enabled after error + await waitFor(() => { + expect(screen.getByTitle('Action')).not.toBeDisabled(); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('props usage', () => { + it('should use correct meeting ID', async () => { + const mockAnnotation = { + id: 'ann-7', + meeting_id: 'different-meeting', + annotation_type: 'action_item', + text: '', + start_time: 0, + end_time: 5, + segment_ids: [42], + created_at: Date.now() / 1000, + }; + vi.mocked(api.annotations.add).mockResolvedValue(mockAnnotation); + + render( + + ); + + const actionButton = screen.getByTitle('Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(api.annotations.add).toHaveBeenCalledWith( + 'different-meeting', + 'action_item', + '', + 0, + 5, + [42] + ); + }); + }); + }); +}); diff --git a/client/src/components/annotations/AnnotationToolbar.tsx b/client/src/components/annotations/AnnotationToolbar.tsx new file mode 100644 index 0000000..0f640b1 --- /dev/null +++ b/client/src/components/annotations/AnnotationToolbar.tsx @@ -0,0 +1,110 @@ +import { AlertCircle, AlertTriangle, CheckSquare, Plus, StickyNote } from 'lucide-react'; +import { useState } from 'react'; +import { api } from '@/lib/tauri'; +import { useStore } from '@/store'; +import type { AnnotationType } from '@/types/annotation'; + +interface AnnotationToolbarProps { + meetingId: string; + segmentId: number; + startTime: number; + endTime: number; +} + +const ANNOTATION_TYPES: Array<{ + type: AnnotationType; + icon: typeof CheckSquare; + label: string; + color: string; +}> = [ + { type: 'action_item', icon: CheckSquare, label: 'Action', color: 'text-green-500' }, + { type: 'decision', icon: AlertCircle, label: 'Decision', color: 'text-purple-500' }, + { type: 'note', icon: StickyNote, label: 'Note', color: 'text-blue-500' }, + { type: 'risk', icon: AlertTriangle, label: 'Risk', color: 'text-red-500' }, +]; + +export function AnnotationToolbar({ + meetingId, + segmentId, + startTime, + endTime, +}: AnnotationToolbarProps) { + const { addAnnotation } = useStore(); + const [showNoteInput, setShowNoteInput] = useState(false); + const [noteText, setNoteText] = useState(''); + const [loading, setLoading] = useState(false); + + const handleAddAnnotation = async (type: AnnotationType, text?: string) => { + setLoading(true); + try { + const annotation = await api.annotations.add( + meetingId, + type, + text ?? '', + startTime, + endTime, + [segmentId], + ); + addAnnotation(annotation); + setShowNoteInput(false); + setNoteText(''); + } catch (error) { + console.error('Failed to add annotation:', error); + } finally { + setLoading(false); + } + }; + + return ( +
+ {ANNOTATION_TYPES.map(({ type, icon: Icon, label, color }) => ( + + ))} + + {showNoteInput && ( +
+ setNoteText(e.target.value)} + placeholder="Add note..." + className="w-32 rounded border border-border bg-background px-2 py-1 text-xs" + onKeyDown={(e) => { + if (e.key === 'Enter' && noteText.trim()) { + handleAddAnnotation('note', noteText.trim()); + } + if (e.key === 'Escape') { + setShowNoteInput(false); + setNoteText(''); + } + }} + /> + +
+ )} +
+ ); +} diff --git a/client/src/components/annotations/index.ts b/client/src/components/annotations/index.ts new file mode 100644 index 0000000..9a27986 --- /dev/null +++ b/client/src/components/annotations/index.ts @@ -0,0 +1,3 @@ +export { AnnotationBadge } from './AnnotationBadge'; +export { AnnotationList } from './AnnotationList'; +export { AnnotationToolbar } from './AnnotationToolbar'; diff --git a/client/src/components/connection/ConnectionPanel.test.tsx b/client/src/components/connection/ConnectionPanel.test.tsx index 5a6bd8b..74dc53e 100644 --- a/client/src/components/connection/ConnectionPanel.test.tsx +++ b/client/src/components/connection/ConnectionPanel.test.tsx @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ConnectionPanel } from './ConnectionPanel'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { api } from '@/lib/tauri'; +import { ConnectionPanel } from './ConnectionPanel'; // Mock the store vi.mock('@/store', () => ({ @@ -19,6 +19,7 @@ vi.mock('@/lib/tauri', () => ({ })); import { useStore } from '@/store'; + const mockUseStore = vi.mocked(useStore); describe('ConnectionPanel', () => { @@ -49,7 +50,15 @@ describe('ConnectionPanel', () => { }); it('should call connect when clicking connect button', async () => { - const mockServerInfo = { version: '1.0.0', capabilities: [] }; + const mockServerInfo = { + version: '1.0.0', + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, + }; vi.mocked(api.connection.connect).mockResolvedValue(mockServerInfo); render(); @@ -62,7 +71,15 @@ describe('ConnectionPanel', () => { }); it('should update connection state on successful connect', async () => { - const mockServerInfo = { version: '1.0.0', capabilities: ['streaming'] }; + const mockServerInfo = { + version: '1.0.0', + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, + }; vi.mocked(api.connection.connect).mockResolvedValue(mockServerInfo); render(); @@ -96,7 +113,15 @@ describe('ConnectionPanel', () => { mockUseStore.mockReturnValue({ connected: true, serverAddress: 'localhost:50051', - serverInfo: { version: '1.0.0', capabilities: ['streaming'], asr_model: 'whisper-large' }, + serverInfo: { + version: '1.0.0', + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, + }, setConnectionState: mockSetConnectionState, }); }); diff --git a/client/src/components/connection/ConnectionPanel.tsx b/client/src/components/connection/ConnectionPanel.tsx index 2a4d889..4a0775e 100644 --- a/client/src/components/connection/ConnectionPanel.tsx +++ b/client/src/components/connection/ConnectionPanel.tsx @@ -1,7 +1,7 @@ +import { RefreshCw, Wifi, WifiOff } from 'lucide-react'; import { useState } from 'react'; -import { useStore } from '@/store'; import { api } from '@/lib/tauri'; -import { Wifi, WifiOff, RefreshCw } from 'lucide-react'; +import { useStore } from '@/store'; export function ConnectionPanel() { const { connected, serverAddress, serverInfo, setConnectionState } = useStore(); diff --git a/client/src/components/meetings/MeetingLibrary.tsx b/client/src/components/meetings/MeetingLibrary.tsx index b55b54a..585e503 100644 --- a/client/src/components/meetings/MeetingLibrary.tsx +++ b/client/src/components/meetings/MeetingLibrary.tsx @@ -1,8 +1,8 @@ +import { Calendar, Clock, Download, Loader2, MessageSquare, Search, Trash2 } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { useStore } from '@/store'; import { api } from '@/lib/tauri'; import { formatDateTime, formatTime } from '@/lib/utils'; -import { Search, Trash2, Download, Loader2, Calendar, Clock, MessageSquare } from 'lucide-react'; +import { useStore } from '@/store'; import type { MeetingInfo } from '@/types'; export function MeetingLibrary() { diff --git a/client/src/components/playback/PlaybackControls.test.tsx b/client/src/components/playback/PlaybackControls.test.tsx new file mode 100644 index 0000000..e6e8e9e --- /dev/null +++ b/client/src/components/playback/PlaybackControls.test.tsx @@ -0,0 +1,261 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { api } from '@/lib/tauri'; +import { PlaybackControls } from './PlaybackControls'; + +// Mock the store +vi.mock('@/store', () => ({ + useStore: vi.fn(), +})); + +// Mock the tauri API +vi.mock('@/lib/tauri', () => ({ + api: { + playback: { + play: vi.fn(), + pause: vi.fn(), + stop: vi.fn(), + seek: vi.fn(), + }, + }, +})); + +import { useStore } from '@/store'; + +const mockUseStore = vi.mocked(useStore); + +describe('PlaybackControls', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('when stopped', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + playbackState: 'stopped', + playbackPosition: 0, + playbackDuration: 120, + }); + }); + + it('should show play button', () => { + render(); + // Play icon should be visible (not Pause) + const playButton = screen.getAllByRole('button')[0]; + expect(playButton).toBeInTheDocument(); + }); + + it('should display time as 00:00', () => { + render(); + expect(screen.getByText('00:00')).toBeInTheDocument(); + }); + + it('should display duration', () => { + render(); + expect(screen.getByText('02:00')).toBeInTheDocument(); + }); + + it('should call play when clicking play button', async () => { + vi.mocked(api.playback.play).mockResolvedValue(undefined); + + render(); + const playButton = screen.getAllByRole('button')[0]; + fireEvent.click(playButton); + + await waitFor(() => { + expect(api.playback.play).toHaveBeenCalled(); + }); + }); + + it('should have disabled stop button', () => { + render(); + const stopButton = screen.getAllByRole('button')[1]; + expect(stopButton).toBeDisabled(); + }); + }); + + describe('when playing', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + playbackState: 'playing', + playbackPosition: 30.5, + playbackDuration: 120, + }); + }); + + it('should show pause button', () => { + render(); + // Pause icon should be visible when playing + const pauseButton = screen.getAllByRole('button')[0]; + expect(pauseButton).toBeInTheDocument(); + }); + + it('should display current position', () => { + render(); + expect(screen.getByText('00:30')).toBeInTheDocument(); + }); + + it('should call pause when clicking pause button', async () => { + vi.mocked(api.playback.pause).mockResolvedValue(undefined); + + render(); + const pauseButton = screen.getAllByRole('button')[0]; + fireEvent.click(pauseButton); + + await waitFor(() => { + expect(api.playback.pause).toHaveBeenCalled(); + }); + }); + + it('should have enabled stop button', () => { + render(); + const stopButton = screen.getAllByRole('button')[1]; + expect(stopButton).not.toBeDisabled(); + }); + + it('should call stop when clicking stop button', async () => { + vi.mocked(api.playback.stop).mockResolvedValue(undefined); + + render(); + const stopButton = screen.getAllByRole('button')[1]; + fireEvent.click(stopButton); + + await waitFor(() => { + expect(api.playback.stop).toHaveBeenCalled(); + }); + }); + }); + + describe('when paused', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + playbackState: 'paused', + playbackPosition: 45, + playbackDuration: 120, + }); + }); + + it('should show play button (not pause)', () => { + render(); + const playButton = screen.getAllByRole('button')[0]; + expect(playButton).toBeInTheDocument(); + }); + + it('should preserve position display', () => { + render(); + expect(screen.getByText('00:45')).toBeInTheDocument(); + }); + + it('should have enabled stop button', () => { + render(); + const stopButton = screen.getAllByRole('button')[1]; + expect(stopButton).not.toBeDisabled(); + }); + }); + + describe('seek functionality', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + playbackState: 'playing', + playbackPosition: 30, + playbackDuration: 120, + }); + }); + + it('should call seek when slider changes', async () => { + vi.mocked(api.playback.seek).mockResolvedValue(undefined); + + render(); + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: '60' } }); + + await waitFor(() => { + expect(api.playback.seek).toHaveBeenCalledWith(60); + }); + }); + + it('should have correct slider range', () => { + render(); + const slider = screen.getByRole('slider'); + expect(slider).toHaveAttribute('min', '0'); + expect(slider).toHaveAttribute('max', '120'); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + playbackState: 'stopped', + playbackPosition: 0, + playbackDuration: 120, + }); + }); + + it('should handle play error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(api.playback.play).mockRejectedValue(new Error('Playback failed')); + + render(); + const playButton = screen.getAllByRole('button')[0]; + fireEvent.click(playButton); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to play:', expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + + it('should handle seek error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(api.playback.seek).mockRejectedValue(new Error('Seek failed')); + + render(); + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: '60' } }); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to seek:', expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('edge cases', () => { + it('should handle zero duration', () => { + mockUseStore.mockReturnValue({ + playbackState: 'stopped', + playbackPosition: 0, + playbackDuration: 0, + }); + + render(); + expect(screen.getAllByText('00:00')).toHaveLength(2); + }); + + it('should handle very long duration', () => { + mockUseStore.mockReturnValue({ + playbackState: 'stopped', + playbackPosition: 0, + playbackDuration: 7200, // 2 hours + }); + + render(); + // Format may be "2:00:00" or "02:00:00" depending on implementation + expect(screen.getByText(/2:00:00$/)).toBeInTheDocument(); + }); + + it('should handle position beyond duration', () => { + mockUseStore.mockReturnValue({ + playbackState: 'playing', + playbackPosition: 150, + playbackDuration: 120, + }); + + render(); + // Should still render without crashing + expect(screen.getByText('02:30')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/playback/PlaybackControls.tsx b/client/src/components/playback/PlaybackControls.tsx index 3fe6343..36789c6 100644 --- a/client/src/components/playback/PlaybackControls.tsx +++ b/client/src/components/playback/PlaybackControls.tsx @@ -1,7 +1,7 @@ -import { useStore } from '@/store'; +import { Pause, Play, Square } from 'lucide-react'; import { api } from '@/lib/tauri'; import { formatTime } from '@/lib/utils'; -import { Play, Pause, Square } from 'lucide-react'; +import { useStore } from '@/store'; export function PlaybackControls() { const { playbackState, playbackPosition, playbackDuration } = useStore(); diff --git a/client/src/components/recording/RecordingPanel.test.tsx b/client/src/components/recording/RecordingPanel.test.tsx new file mode 100644 index 0000000..a897adc --- /dev/null +++ b/client/src/components/recording/RecordingPanel.test.tsx @@ -0,0 +1,303 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { api } from '@/lib/tauri'; +import { RecordingPanel } from './RecordingPanel'; + +// Mock child components to isolate tests +vi.mock('./VuMeter', () => ({ + VuMeter: ({ level }: { level: number }) =>
Level: {level}
, +})); + +vi.mock('./RecordingTimer', () => ({ + RecordingTimer: ({ seconds }: { seconds: number }) =>
{seconds}s
, +})); + +vi.mock('@/components/playback/PlaybackControls', () => ({ + PlaybackControls: () =>
Playback Controls
, +})); + +// Mock the store +vi.mock('@/store', () => ({ + useStore: vi.fn(), +})); + +// Mock the tauri API +vi.mock('@/lib/tauri', () => ({ + api: { + recording: { + start: vi.fn(), + stop: vi.fn(), + }, + }, +})); + +import { useStore } from '@/store'; + +const mockUseStore = vi.mocked(useStore); +const mockSetRecording = vi.fn(); +const mockSetCurrentMeeting = vi.fn(); +const mockClearTranscript = vi.fn(); + +const defaultStoreState = { + recording: false, + currentMeeting: null, + connected: true, + audioLevel: 0.5, + elapsedSeconds: 0, + setRecording: mockSetRecording, + setCurrentMeeting: mockSetCurrentMeeting, + clearTranscript: mockClearTranscript, +}; + +describe('RecordingPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseStore.mockReturnValue(defaultStoreState); + }); + + describe('when disconnected', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + connected: false, + }); + }); + + it('should show disabled start button', () => { + render(); + const startButton = screen.getByRole('button', { name: /start recording/i }); + expect(startButton).toBeDisabled(); + }); + + it('should show VU meter', () => { + render(); + expect(screen.getByTestId('vu-meter')).toBeInTheDocument(); + }); + }); + + describe('when connected and not recording', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + connected: true, + recording: false, + }); + }); + + it('should show enabled Start Recording button', () => { + render(); + const startButton = screen.getByRole('button', { name: /start recording/i }); + expect(startButton).not.toBeDisabled(); + }); + + it('should not show recording timer', () => { + render(); + expect(screen.queryByTestId('timer')).not.toBeInTheDocument(); + }); + + it('should not show playback controls without meeting', () => { + render(); + expect(screen.queryByTestId('playback-controls')).not.toBeInTheDocument(); + }); + + it('should show playback controls with meeting', () => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + currentMeeting: { id: 'meeting-1', title: 'Test Meeting' }, + }); + render(); + expect(screen.getByTestId('playback-controls')).toBeInTheDocument(); + }); + }); + + describe('start recording flow', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + connected: true, + recording: false, + }); + }); + + it('should call api.recording.start on click', async () => { + const mockMeeting = { + id: 'meeting-123', + title: 'Meeting 1/1/2025', + state: 'recording', + }; + vi.mocked(api.recording.start).mockResolvedValue(mockMeeting as any); + + render(); + const startButton = screen.getByRole('button', { name: /start recording/i }); + fireEvent.click(startButton); + + await waitFor(() => { + expect(api.recording.start).toHaveBeenCalled(); + }); + }); + + it('should update store state after successful start', async () => { + const mockMeeting = { + id: 'meeting-123', + title: 'Test Meeting', + state: 'recording', + }; + vi.mocked(api.recording.start).mockResolvedValue(mockMeeting as any); + + render(); + const startButton = screen.getByRole('button', { name: /start recording/i }); + fireEvent.click(startButton); + + await waitFor(() => { + expect(mockSetCurrentMeeting).toHaveBeenCalledWith(mockMeeting); + expect(mockSetRecording).toHaveBeenCalledWith(true); + expect(mockClearTranscript).toHaveBeenCalled(); + }); + }); + + it('should show loading state while starting', async () => { + vi.mocked(api.recording.start).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({} as any), 100)) + ); + + render(); + const startButton = screen.getByRole('button', { name: /start recording/i }); + fireEvent.click(startButton); + + // Button should be disabled during loading + expect(startButton).toBeDisabled(); + }); + + it('should handle start error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(api.recording.start).mockRejectedValue(new Error('Start failed')); + + render(); + const startButton = screen.getByRole('button', { name: /start recording/i }); + fireEvent.click(startButton); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to start recording:', expect.any(Error)); + }); + + // Button should be re-enabled + await waitFor(() => { + expect(startButton).not.toBeDisabled(); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('when recording', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + connected: true, + recording: true, + elapsedSeconds: 45, + currentMeeting: { id: 'meeting-1', title: 'Test' }, + }); + }); + + it('should show Stop button instead of Start', () => { + render(); + expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /start recording/i })).not.toBeInTheDocument(); + }); + + it('should show recording timer', () => { + render(); + expect(screen.getByTestId('timer')).toBeInTheDocument(); + expect(screen.getByText('45s')).toBeInTheDocument(); + }); + + it('should not show playback controls while recording', () => { + render(); + expect(screen.queryByTestId('playback-controls')).not.toBeInTheDocument(); + }); + }); + + describe('stop recording flow', () => { + beforeEach(() => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + connected: true, + recording: true, + currentMeeting: { id: 'meeting-1', title: 'Test' }, + }); + }); + + it('should call api.recording.stop on click', async () => { + const mockMeeting = { + id: 'meeting-123', + title: 'Test Meeting', + state: 'stopped', + }; + vi.mocked(api.recording.stop).mockResolvedValue(mockMeeting as any); + + render(); + const stopButton = screen.getByRole('button', { name: /stop/i }); + fireEvent.click(stopButton); + + await waitFor(() => { + expect(api.recording.stop).toHaveBeenCalled(); + }); + }); + + it('should update store state after successful stop', async () => { + const mockMeeting = { + id: 'meeting-123', + title: 'Test Meeting', + state: 'stopped', + }; + vi.mocked(api.recording.stop).mockResolvedValue(mockMeeting as any); + + render(); + const stopButton = screen.getByRole('button', { name: /stop/i }); + fireEvent.click(stopButton); + + await waitFor(() => { + expect(mockSetCurrentMeeting).toHaveBeenCalledWith(mockMeeting); + expect(mockSetRecording).toHaveBeenCalledWith(false); + }); + }); + + it('should handle stop error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(api.recording.stop).mockRejectedValue(new Error('Stop failed')); + + render(); + const stopButton = screen.getByRole('button', { name: /stop/i }); + fireEvent.click(stopButton); + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to stop recording:', expect.any(Error)); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('VU meter integration', () => { + it('should pass audio level to VU meter', () => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + audioLevel: 0.75, + }); + + render(); + expect(screen.getByText('Level: 0.75')).toBeInTheDocument(); + }); + + it('should handle zero audio level', () => { + mockUseStore.mockReturnValue({ + ...defaultStoreState, + audioLevel: 0, + }); + + render(); + expect(screen.getByText('Level: 0')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/recording/RecordingPanel.tsx b/client/src/components/recording/RecordingPanel.tsx index 1167436..3f645a8 100644 --- a/client/src/components/recording/RecordingPanel.tsx +++ b/client/src/components/recording/RecordingPanel.tsx @@ -1,10 +1,10 @@ +import { Loader2, Mic, Square } from 'lucide-react'; import { useState } from 'react'; -import { useStore } from '@/store'; -import { api } from '@/lib/tauri'; -import { formatTime } from '@/lib/utils'; -import { VuMeter } from './VuMeter'; import { PlaybackControls } from '@/components/playback/PlaybackControls'; -import { Mic, Square, Loader2 } from 'lucide-react'; +import { api } from '@/lib/tauri'; +import { useStore } from '@/store'; +import { RecordingTimer } from './RecordingTimer'; +import { VuMeter } from './VuMeter'; export function RecordingPanel() { const { @@ -90,12 +90,7 @@ export function RecordingPanel() { {/* Recording timer */} - {recording && ( -
-
- {formatTime(elapsedSeconds)} -
- )} + {recording && }
{/* Playback controls (when not recording and have a meeting) */} diff --git a/client/src/components/recording/RecordingTimer.tsx b/client/src/components/recording/RecordingTimer.tsx new file mode 100644 index 0000000..d79020c --- /dev/null +++ b/client/src/components/recording/RecordingTimer.tsx @@ -0,0 +1,33 @@ +import { cn, formatTime } from '@/lib/utils'; + +interface RecordingTimerProps { + /** Elapsed seconds to display */ + seconds: number; + /** Whether to show the pulsing recording indicator */ + showIndicator?: boolean; + /** Additional CSS classes */ + className?: string; +} + +/** + * Recording timer display component. + * + * Displays elapsed time in MM:SS or HH:MM:SS format with an optional + * pulsing red indicator to show active recording. + * + * Uses formatTime() from lib/utils for consistent time formatting. + */ +export function RecordingTimer({ + seconds, + showIndicator = true, + className, +}: RecordingTimerProps) { + return ( +
+ {showIndicator && ( +
+ )} + {formatTime(seconds)} +
+ ); +} diff --git a/client/src/components/recording/VuMeter.test.tsx b/client/src/components/recording/VuMeter.test.tsx index 213f4eb..fd494f1 100644 --- a/client/src/components/recording/VuMeter.test.tsx +++ b/client/src/components/recording/VuMeter.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; import { VuMeter } from './VuMeter'; describe('VuMeter', () => { diff --git a/client/src/components/settings/SettingsField.tsx b/client/src/components/settings/SettingsField.tsx new file mode 100644 index 0000000..4171a58 --- /dev/null +++ b/client/src/components/settings/SettingsField.tsx @@ -0,0 +1,17 @@ +interface SettingsFieldProps { + label: string; + description?: string; + children: React.ReactNode; +} + +export function SettingsField({ label, description, children }: SettingsFieldProps) { + return ( +
+
+ {label} + {description &&

{description}

} +
+
{children}
+
+ ); +} diff --git a/client/src/components/settings/SettingsPanel.tsx b/client/src/components/settings/SettingsPanel.tsx new file mode 100644 index 0000000..f29e4a1 --- /dev/null +++ b/client/src/components/settings/SettingsPanel.tsx @@ -0,0 +1,220 @@ +import { Brain, HardDrive, Loader2, Save, Server, Shield, Zap } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { api } from '@/lib/tauri'; +import type { SummarizationProvider, UserPreferences } from '@/types'; +import { SettingsField } from './SettingsField'; +import { SettingsSection } from './SettingsSection'; + +const DEFAULT_PREFERENCES: UserPreferences = { + serverUrl: 'localhost:50051', + dataDirectory: '', + encryptionEnabled: true, + autoStartEnabled: true, + triggerConfidenceThreshold: 0.7, + summarizationProvider: 'none', + cloudApiKey: '', + ollamaUrl: 'http://localhost:11434', +}; + +export function SettingsPanel() { + const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const loadPrefs = async () => { + try { + const prefs = await api.preferences.get(); + setPreferences(prefs); + } catch (err) { + setError(`Failed to load preferences: ${err}`); + } finally { + setLoading(false); + } + }; + loadPrefs(); + }, []); + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await api.preferences.save(preferences); + } catch (err) { + setError(`Failed to save: ${err}`); + } finally { + setSaving(false); + } + }; + + const updateField = (field: K, value: UserPreferences[K]) => { + setPreferences((prev) => ({ ...prev, [field]: value })); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+

Settings

+ +
+ + {error && ( +
{error}
+ )} + + + + updateField('serverUrl', e.target.value)} + className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm" + placeholder="localhost:50051" + /> + + + + + + updateField('dataDirectory', e.target.value)} + className="w-64 rounded-md border border-border bg-background px-3 py-1.5 text-sm" + placeholder="/path/to/data" + /> + + + + + + updateField('encryptionEnabled', v)} + /> + + + + + + updateField('autoStartEnabled', v)} + /> + + +
+ + updateField('triggerConfidenceThreshold', parseFloat(e.target.value)) + } + className="w-24" + /> + + {Math.round(preferences.triggerConfidenceThreshold * 100)}% + +
+
+
+ + + + + + + {preferences.summarizationProvider === 'cloud' && ( + + updateField('cloudApiKey', e.target.value)} + className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm" + placeholder="sk-..." + /> + + )} + + {preferences.summarizationProvider === 'ollama' && ( + + updateField('ollamaUrl', e.target.value)} + className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm" + placeholder="http://localhost:11434" + /> + + )} + +
+
+ ); +} + +function ToggleSwitch({ + checked, + onChange, +}: { + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} diff --git a/client/src/components/settings/SettingsSection.tsx b/client/src/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..14c3837 --- /dev/null +++ b/client/src/components/settings/SettingsSection.tsx @@ -0,0 +1,19 @@ +import type { LucideIcon } from 'lucide-react'; + +interface SettingsSectionProps { + icon: LucideIcon; + title: string; + children: React.ReactNode; +} + +export function SettingsSection({ icon: Icon, title, children }: SettingsSectionProps) { + return ( +
+
+ +

{title}

+
+
{children}
+
+ ); +} diff --git a/client/src/components/settings/index.ts b/client/src/components/settings/index.ts new file mode 100644 index 0000000..7c0560b --- /dev/null +++ b/client/src/components/settings/index.ts @@ -0,0 +1,3 @@ +export { SettingsField } from './SettingsField'; +export { SettingsPanel } from './SettingsPanel'; +export { SettingsSection } from './SettingsSection'; diff --git a/client/src/components/summary/SummaryPanel.tsx b/client/src/components/summary/SummaryPanel.tsx index 9deb437..b9cdc6f 100644 --- a/client/src/components/summary/SummaryPanel.tsx +++ b/client/src/components/summary/SummaryPanel.tsx @@ -1,7 +1,7 @@ +import { AlertCircle, CheckCircle, Loader2, RefreshCw } from 'lucide-react'; import { useState } from 'react'; -import { useStore } from '@/store'; import { api } from '@/lib/tauri'; -import { Loader2, RefreshCw, AlertCircle, CheckCircle } from 'lucide-react'; +import { useStore } from '@/store'; export function SummaryPanel() { const { diff --git a/client/src/components/transcript/TranscriptView.tsx b/client/src/components/transcript/TranscriptView.tsx index c11fb71..1f9a510 100644 --- a/client/src/components/transcript/TranscriptView.tsx +++ b/client/src/components/transcript/TranscriptView.tsx @@ -1,11 +1,19 @@ -import { useRef, useEffect } from 'react'; -import { useStore } from '@/store'; +import { useEffect, useRef } from 'react'; +import { AnnotationList, AnnotationToolbar } from '@/components/annotations'; import { api } from '@/lib/tauri'; -import { formatTime, getSpeakerColor } from '@/lib/utils'; -import { cn } from '@/lib/utils'; +import { cn, formatTime, getSpeakerColor } from '@/lib/utils'; +import { useStore } from '@/store'; export function TranscriptView() { - const { segments, partialText, highlightedSegment, recording } = useStore(); + const { + segments, + partialText, + highlightedSegment, + recording, + annotations, + currentMeeting, + removeAnnotation, + } = useStore(); const containerRef = useRef(null); const highlightedRef = useRef(null); @@ -39,31 +47,63 @@ export function TranscriptView() { {segments.map((segment, index) => { const isHighlighted = highlightedSegment === index; const speakerColor = getSpeakerColor(segment.speaker_id); + const segmentAnnotations = annotations.filter((a) => + a.segment_ids.includes(segment.segment_id) + ); + + const handleRemoveAnnotation = async (annotationId: string) => { + try { + await api.annotations.delete(annotationId); + removeAnnotation(annotationId); + } catch (error) { + console.error('Failed to remove annotation:', error); + } + }; return (
handleSegmentClick(segment.start_time)} className={cn( - 'rounded-lg p-3 cursor-pointer transition-colors', + 'group rounded-lg p-3 transition-colors', isHighlighted ? 'bg-primary/20 border border-primary' : 'bg-muted/50 hover:bg-muted' )} > -
- - {segment.speaker_id || 'Unknown'} - - - {formatTime(segment.start_time)} - {formatTime(segment.end_time)} - +
handleSegmentClick(segment.start_time)} + className="cursor-pointer" + > +
+ + {segment.speaker_id || 'Unknown'} + + + {formatTime(segment.start_time)} - {formatTime(segment.end_time)} + +
+

{segment.text}

-

{segment.text}

+ + + + {currentMeeting && ( +
+ +
+ )}
); })} diff --git a/client/src/components/triggers/TriggerDialog.tsx b/client/src/components/triggers/TriggerDialog.tsx index c6439d5..9a37ebb 100644 --- a/client/src/components/triggers/TriggerDialog.tsx +++ b/client/src/components/triggers/TriggerDialog.tsx @@ -1,6 +1,6 @@ -import { useStore } from '@/store'; +import { AlertCircle, Clock, Play, X } from 'lucide-react'; import { api } from '@/lib/tauri'; -import { AlertCircle, Play, Clock, X } from 'lucide-react'; +import { useStore } from '@/store'; export function TriggerDialog() { const { diff --git a/client/src/hooks/useTauriEvents.test.ts b/client/src/hooks/useTauriEvents.test.ts index 734f5ec..b966d11 100644 --- a/client/src/hooks/useTauriEvents.test.ts +++ b/client/src/hooks/useTauriEvents.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useTauriEvents } from './useTauriEvents'; // Mock the Tauri event API @@ -24,6 +24,7 @@ vi.mock('@/store', () => ({ })); import { listen } from '@tauri-apps/api/event'; + const mockListen = vi.mocked(listen); describe('useTauriEvents', () => { diff --git a/client/src/hooks/useTauriEvents.ts b/client/src/hooks/useTauriEvents.ts index 797ea20..7723981 100644 --- a/client/src/hooks/useTauriEvents.ts +++ b/client/src/hooks/useTauriEvents.ts @@ -1,8 +1,8 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { useEffect, useRef } from 'react'; -import { listen, UnlistenFn } from '@tauri-apps/api/event'; -import { useStore } from '@/store'; import { Events, PlaybackStates } from '@/lib/constants'; -import type { TranscriptUpdate, TriggerDecision, ConnectionEvent, PlaybackState } from '@/types'; +import { useStore } from '@/store'; +import type { ConnectionEvent, PlaybackState, TranscriptUpdate, TriggerDecision } from '@/types'; export function useTauriEvents() { const store = useStore(); diff --git a/client/src/lib/cache.test.ts b/client/src/lib/cache.test.ts index e359b58..43a6209 100644 --- a/client/src/lib/cache.test.ts +++ b/client/src/lib/cache.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { CacheKey, resetCache, getCache, type Cache, type CacheStats } from './cache'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Cache, CacheKey, type CacheStats, getCache, resetCache } from './cache'; // Helper to create a test MemoryCache with controlled timing class TestableMemoryCache implements Cache { diff --git a/client/src/lib/cache.ts b/client/src/lib/cache.ts index b26f967..b4dbce7 100644 --- a/client/src/lib/cache.ts +++ b/client/src/lib/cache.ts @@ -494,11 +494,11 @@ export function cached( key: string | ((args: unknown[]) => string), ttlSecs?: number ) { - return function ( + return ( _target: unknown, _propertyKey: string, descriptor: PropertyDescriptor - ) { + ) => { const originalMethod = descriptor.value; descriptor.value = async function (...args: unknown[]) { diff --git a/client/src/lib/constants.test.ts b/client/src/lib/constants.test.ts index c13e614..6e4e30c 100644 --- a/client/src/lib/constants.test.ts +++ b/client/src/lib/constants.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { - Events, - PlaybackStates, AnnotationTypes, + Defaults, + Events, ExportFormats, MeetingStates, + PlaybackStates, ViewModes, - Defaults, } from './constants'; describe('Events', () => { diff --git a/client/src/lib/tauri.ts b/client/src/lib/tauri.ts index 05df5bb..5c3c368 100644 --- a/client/src/lib/tauri.ts +++ b/client/src/lib/tauri.ts @@ -6,21 +6,22 @@ */ import { invoke } from '@tauri-apps/api/core'; -import { getCache, CacheKey } from './cache'; import type { - ServerInfo, - MeetingInfo, - MeetingDetails, AnnotationInfo, - SummaryInfo, - ExportResult, - DiarizationResult, - RenameSpeakerResult, - PlaybackInfo, - TriggerStatus, - AudioDeviceInfo, AppStatus, + AudioDeviceInfo, + DiarizationResult, + ExportResult, + MeetingDetails, + MeetingInfo, + PlaybackInfo, + RenameSpeakerResult, + ServerInfo, + SummaryInfo, + TriggerStatus, + UserPreferences, } from '@/types'; +import { CacheKey, getCache } from './cache'; /** Cache TTL constants (in seconds) */ const CACHE_TTL = { @@ -336,6 +337,19 @@ class AudioService { } } +/** + * Preferences service - manages user preferences + */ +class PreferencesService { + get(): Promise { + return invoke('get_preferences'); + } + + save(preferences: UserPreferences): Promise { + return invoke('save_preferences', { preferences }); + } +} + /** * NoteFlow API facade - unified interface for all Tauri commands * @@ -359,6 +373,7 @@ class NoteFlowApi { readonly playback = new PlaybackService(); readonly triggers = new TriggerService(); readonly audio = new AudioService(); + readonly preferences = new PreferencesService(); } /** Singleton API instance */ diff --git a/client/src/lib/utils.test.ts b/client/src/lib/utils.test.ts index a898758..e2b6a44 100644 --- a/client/src/lib/utils.test.ts +++ b/client/src/lib/utils.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { cn, formatTime, formatDateTime, getSpeakerColor } from './utils'; +import { describe, expect, it } from 'vitest'; +import { cn, formatDateTime, formatTime, getSpeakerColor } from './utils'; describe('cn (className merger)', () => { it('should merge class names', () => { @@ -86,8 +86,6 @@ describe('getSpeakerColor', () => { }); it('should return different colors for different speakers', () => { - const color1 = getSpeakerColor('speaker-1'); - const color2 = getSpeakerColor('speaker-2'); // Not guaranteed to be different due to hash collisions, but should be in most cases // This is a probabilistic test const colors = [ diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 4d46466..741c2bf 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -10,18 +10,18 @@ import { devtools, persist } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { + type ConnectionSlice, createConnectionSlice, + createMeetingsSlice, + createPlaybackSlice, createRecordingSlice, createTranscriptSlice, - createPlaybackSlice, - createMeetingsSlice, createTriggersSlice, createViewSlice, - type ConnectionSlice, + type MeetingsSlice, + type PlaybackSlice, type RecordingSlice, type TranscriptSlice, - type PlaybackSlice, - type MeetingsSlice, type TriggersSlice, type ViewSlice, } from './slices'; diff --git a/client/src/store/slices/connection.test.ts b/client/src/store/slices/connection.test.ts index ca25336..35b13cd 100644 --- a/client/src/store/slices/connection.test.ts +++ b/client/src/store/slices/connection.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { createConnectionSlice, ConnectionSlice } from './connection'; import type { ServerInfo } from '@/types'; +import { type ConnectionSlice, createConnectionSlice } from './connection'; const createTestStore = () => create()(immer((...a) => ({ ...createConnectionSlice(...a) }))); @@ -27,7 +27,12 @@ describe('ConnectionSlice', () => { it('should update connection to connected state with server info', () => { const serverInfo: ServerInfo = { version: '1.0.0', - capabilities: ['streaming', 'diarization'], + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, }; store.getState().setConnectionState(true, '192.168.1.1:50051', serverInfo); @@ -40,7 +45,16 @@ describe('ConnectionSlice', () => { it('should update connection to disconnected state', () => { // First connect - store.getState().setConnectionState(true, 'localhost:50051', { version: '1.0.0', capabilities: [] }); + const serverInfo: ServerInfo = { + version: '1.0.0', + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, + }; + store.getState().setConnectionState(true, 'localhost:50051', serverInfo); // Then disconnect store.getState().setConnectionState(false, 'localhost:50051'); @@ -51,7 +65,15 @@ describe('ConnectionSlice', () => { }); it('should clear server info when not provided', () => { - const serverInfo: ServerInfo = { version: '1.0.0', capabilities: [] }; + const serverInfo: ServerInfo = { + version: '1.0.0', + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, + }; store.getState().setConnectionState(true, 'localhost:50051', serverInfo); store.getState().setConnectionState(false, 'localhost:50051'); diff --git a/client/src/store/slices/index.ts b/client/src/store/slices/index.ts index f470d0c..1d6e99a 100644 --- a/client/src/store/slices/index.ts +++ b/client/src/store/slices/index.ts @@ -2,10 +2,10 @@ * Store slices barrel export */ -export { createConnectionSlice, type ConnectionSlice } from './connection'; +export { type ConnectionSlice, createConnectionSlice } from './connection'; +export { createMeetingsSlice, type MeetingsSlice } from './meetings'; +export { createPlaybackSlice, type PlaybackSlice } from './playback'; export { createRecordingSlice, type RecordingSlice } from './recording'; export { createTranscriptSlice, type TranscriptSlice } from './transcript'; -export { createPlaybackSlice, type PlaybackSlice } from './playback'; -export { createMeetingsSlice, type MeetingsSlice } from './meetings'; export { createTriggersSlice, type TriggersSlice } from './triggers'; -export { createViewSlice, type ViewSlice, type ViewMode } from './view'; +export { createViewSlice, type ViewMode, type ViewSlice } from './view'; diff --git a/client/src/store/slices/meetings.test.ts b/client/src/store/slices/meetings.test.ts index 362e9b7..1f1705a 100644 --- a/client/src/store/slices/meetings.test.ts +++ b/client/src/store/slices/meetings.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { createMeetingsSlice, MeetingsSlice } from './meetings'; -import type { MeetingInfo, AnnotationInfo, SummaryInfo } from '@/types'; +import { createMockActionItem, createMockKeyPoint } from '@/test/mocks/tauri'; +import type { AnnotationInfo, MeetingInfo, SummaryInfo } from '@/types'; +import { createMeetingsSlice, type MeetingsSlice } from './meetings'; const createTestStore = () => create()(immer((...a) => ({ ...createMeetingsSlice(...a) }))); @@ -12,39 +13,42 @@ const mockMeetings: MeetingInfo[] = [ id: 'meeting-1', title: 'Meeting 1', state: 'completed', - created_at: '2024-01-01T10:00:00Z', - updated_at: '2024-01-01T11:00:00Z', - duration_secs: 3600, + created_at: Date.now() - 86400000, + started_at: Date.now() - 86400000, + ended_at: Date.now() - 82800000, + duration_seconds: 3600, segment_count: 10, - has_summary: true, }, { id: 'meeting-2', title: 'Meeting 2', state: 'recording', - created_at: '2024-01-02T10:00:00Z', - updated_at: '2024-01-02T10:30:00Z', - duration_secs: 1800, + created_at: Date.now(), + started_at: Date.now(), + ended_at: 0, + duration_seconds: 1800, segment_count: 5, - has_summary: false, }, ]; const mockAnnotation: AnnotationInfo = { id: 'annotation-1', - segment_id: 'segment-1', - content: 'Test annotation', + meeting_id: 'meeting-1', annotation_type: 'note', - created_at: '2024-01-01T10:05:00Z', + text: 'Test annotation', + start_time: 0, + end_time: 300, + segment_ids: [1], + created_at: Date.now() - 86100000, }; const mockSummary: SummaryInfo = { - id: 'summary-1', meeting_id: 'meeting-1', - content: 'Summary content', - key_points: ['Point 1', 'Point 2'], - action_items: ['Action 1'], - created_at: '2024-01-01T11:00:00Z', + executive_summary: 'Summary content', + key_points: [createMockKeyPoint({ text: 'Point 1' }), createMockKeyPoint({ text: 'Point 2' })], + action_items: [createMockActionItem({ text: 'Action 1' })], + generated_at: Date.now() - 82800000, + model_version: '1.0.0', }; describe('MeetingsSlice', () => { diff --git a/client/src/store/slices/meetings.ts b/client/src/store/slices/meetings.ts index d22490b..070e9cd 100644 --- a/client/src/store/slices/meetings.ts +++ b/client/src/store/slices/meetings.ts @@ -3,7 +3,7 @@ */ import type { StateCreator } from 'zustand'; -import type { MeetingInfo, AnnotationInfo, SummaryInfo } from '@/types'; +import type { AnnotationInfo, MeetingInfo, SummaryInfo } from '@/types'; export interface MeetingsSlice { meetings: MeetingInfo[]; diff --git a/client/src/store/slices/playback.test.ts b/client/src/store/slices/playback.test.ts index 2601627..46247b7 100644 --- a/client/src/store/slices/playback.test.ts +++ b/client/src/store/slices/playback.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { createPlaybackSlice, PlaybackSlice } from './playback'; -import type { PlaybackState } from '@/types'; +import { createPlaybackSlice, type PlaybackSlice } from './playback'; const createTestStore = () => create()(immer((...a) => ({ ...createPlaybackSlice(...a) }))); diff --git a/client/src/store/slices/recording.test.ts b/client/src/store/slices/recording.test.ts index 71ea70b..c906940 100644 --- a/client/src/store/slices/recording.test.ts +++ b/client/src/store/slices/recording.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { createRecordingSlice, RecordingSlice } from './recording'; import type { MeetingInfo } from '@/types'; +import { createRecordingSlice, type RecordingSlice } from './recording'; const createTestStore = () => create()(immer((...a) => ({ ...createRecordingSlice(...a) }))); @@ -11,11 +11,11 @@ const mockMeeting: MeetingInfo = { id: 'meeting-123', title: 'Test Meeting', state: 'recording', - created_at: '2024-01-01T10:00:00Z', - updated_at: '2024-01-01T10:00:00Z', - duration_secs: 0, + created_at: Date.now(), + started_at: Date.now(), + ended_at: 0, + duration_seconds: 0, segment_count: 0, - has_summary: false, }; describe('RecordingSlice', () => { diff --git a/client/src/store/slices/transcript.test.ts b/client/src/store/slices/transcript.test.ts index d98594a..7115dc9 100644 --- a/client/src/store/slices/transcript.test.ts +++ b/client/src/store/slices/transcript.test.ts @@ -1,18 +1,21 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { createTranscriptSlice, TranscriptSlice } from './transcript'; import type { Segment } from '@/types'; +import { createTranscriptSlice, type TranscriptSlice } from './transcript'; const createTestStore = () => create()(immer((...a) => ({ ...createTranscriptSlice(...a) }))); const createMockSegment = (overrides?: Partial): Segment => ({ - id: 'segment-1', - speaker: 'Speaker 1', + segment_id: 1, text: 'Hello, this is a test segment.', start_time: 0.0, end_time: 5.0, + language: 'en', + speaker_id: 'speaker-1', + speaker_confidence: 0.9, + words: [], ...overrides, }); @@ -40,9 +43,9 @@ describe('TranscriptSlice', () => { }); it('should add multiple segments in order', () => { - const segment1 = createMockSegment({ id: 'segment-1', start_time: 0 }); - const segment2 = createMockSegment({ id: 'segment-2', start_time: 5 }); - const segment3 = createMockSegment({ id: 'segment-3', start_time: 10 }); + const segment1 = createMockSegment({ segment_id: 1, start_time: 0 }); + const segment2 = createMockSegment({ segment_id: 2, start_time: 5 }); + const segment3 = createMockSegment({ segment_id: 3, start_time: 10 }); store.getState().addSegment(segment1); store.getState().addSegment(segment2); @@ -50,16 +53,16 @@ describe('TranscriptSlice', () => { const segments = store.getState().segments; expect(segments).toHaveLength(3); - expect(segments[0].id).toBe('segment-1'); - expect(segments[1].id).toBe('segment-2'); - expect(segments[2].id).toBe('segment-3'); + expect(segments[0].segment_id).toBe(1); + expect(segments[1].segment_id).toBe(2); + expect(segments[2].segment_id).toBe(3); }); it('should preserve existing segments', () => { - const segment1 = createMockSegment({ id: 'segment-1' }); + const segment1 = createMockSegment({ segment_id: 1 }); store.getState().addSegment(segment1); - const segment2 = createMockSegment({ id: 'segment-2' }); + const segment2 = createMockSegment({ segment_id: 2 }); store.getState().addSegment(segment2); expect(store.getState().segments).toHaveLength(2); @@ -107,7 +110,7 @@ describe('TranscriptSlice', () => { it('should clear all transcript state', () => { // Set up state store.getState().addSegment(createMockSegment()); - store.getState().addSegment(createMockSegment({ id: 'segment-2' })); + store.getState().addSegment(createMockSegment({ segment_id: 2 })); store.getState().setPartialText('Partial...'); store.getState().setHighlightedSegment(0); diff --git a/client/src/store/slices/triggers.test.ts b/client/src/store/slices/triggers.test.ts index 8d7c6e1..4aec9e6 100644 --- a/client/src/store/slices/triggers.test.ts +++ b/client/src/store/slices/triggers.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; -import { createTriggersSlice, TriggersSlice } from './triggers'; import type { TriggerDecision } from '@/types'; +import { createTriggersSlice, type TriggersSlice } from './triggers'; const createTestStore = () => create()(immer((...a) => ({ ...createTriggersSlice(...a) }))); @@ -10,9 +10,9 @@ const createTestStore = () => const mockTriggerDecision: TriggerDecision = { action: 'notify', confidence: 0.85, - reason: 'Calendar event detected', + signals: [], + timestamp: Date.now(), detected_app: 'Zoom', - suggested_title: 'Weekly Standup', }; describe('TriggersSlice', () => { diff --git a/client/src/test/mocks/tauri.ts b/client/src/test/mocks/tauri.ts index e163460..d21a179 100644 --- a/client/src/test/mocks/tauri.ts +++ b/client/src/test/mocks/tauri.ts @@ -1,16 +1,18 @@ import { vi } from 'vitest'; import type { - MeetingInfo, + ActionItem, + AudioDeviceInfo, + KeyPoint, MeetingDetails, + MeetingInfo, MeetingState, - ServerInfo, - SegmentInfo, - SummaryInfo, PlaybackInfo, PlaybackState, - TriggerDecision, + Segment, + ServerInfo, + SummaryInfo, TriggerAction, - AudioDeviceInfo, + TriggerDecision, } from '@/types'; // Factory functions for test data @@ -18,48 +20,68 @@ export const createMockMeeting = (overrides?: Partial): MeetingInfo id: 'meeting-123', title: 'Test Meeting', state: 'recording' as MeetingState, - created_at: '2024-01-01T10:00:00Z', - updated_at: '2024-01-01T10:30:00Z', - duration_secs: 1800, + created_at: Date.now(), + started_at: Date.now(), + ended_at: 0, + duration_seconds: 1800, segment_count: 5, - has_summary: false, ...overrides, }); export const createMockMeetingDetails = (overrides?: Partial): MeetingDetails => ({ - ...createMockMeeting(), + meeting: createMockMeeting(), segments: [], summary: null, ...overrides, }); -export const createMockSegment = (overrides?: Partial): SegmentInfo => ({ - id: 'segment-123', - meeting_id: 'meeting-123', - speaker: 'Speaker 1', +export const createMockSegment = (overrides?: Partial): Segment => ({ + segment_id: 1, text: 'This is a test segment.', start_time: 0.0, end_time: 5.0, - confidence: 0.95, - is_partial: false, - word_timings: [], - annotations: [], + language: 'en', + speaker_id: 'speaker-1', + speaker_confidence: 0.95, + words: [], + ...overrides, +}); + +export const createMockKeyPoint = (overrides?: Partial): KeyPoint => ({ + text: 'Key point text', + segment_ids: [1, 2], + start_time: 0, + end_time: 60, + ...overrides, +}); + +export const createMockActionItem = (overrides?: Partial): ActionItem => ({ + text: 'Action item text', + assignee: '', + due_date: null, + priority: 1, + segment_ids: [1], ...overrides, }); export const createMockSummary = (overrides?: Partial): SummaryInfo => ({ - id: 'summary-123', meeting_id: 'meeting-123', - content: 'Test summary content', - key_points: ['Point 1', 'Point 2'], - action_items: ['Action 1'], - created_at: '2024-01-01T11:00:00Z', + executive_summary: 'Test summary content', + key_points: [createMockKeyPoint()], + action_items: [createMockActionItem()], + generated_at: Date.now(), + model_version: '1.0.0', ...overrides, }); export const createMockServerInfo = (overrides?: Partial): ServerInfo => ({ version: '1.0.0', - capabilities: ['streaming', 'diarization', 'summarization'], + asr_model: 'whisper-large-v3', + asr_ready: true, + uptime_seconds: 3600, + active_meetings: 0, + diarization_enabled: true, + diarization_ready: true, ...overrides, }); @@ -74,9 +96,9 @@ export const createMockPlaybackInfo = (overrides?: Partial): Playb export const createMockTriggerDecision = (overrides?: Partial): TriggerDecision => ({ action: 'notify' as TriggerAction, confidence: 0.8, - reason: 'Calendar event detected', + signals: [], + timestamp: Date.now(), detected_app: 'Zoom', - suggested_title: 'Zoom Meeting', ...overrides, }); @@ -112,7 +134,7 @@ export const defaultMockHandlers: Record = { get_status: { connected: true, address: 'localhost:50051' }, start_recording: createMockMeeting({ state: 'recording' }), stop_recording: createMockMeeting({ state: 'completed' }), - list_meetings: [createMockMeeting()], + list_meetings: [[createMockMeeting()], 1], get_meeting: createMockMeetingDetails(), delete_meeting: true, get_playback_state: createMockPlaybackInfo(), diff --git a/client/src/test/setup.ts b/client/src/test/setup.ts index a616705..a54679f 100644 --- a/client/src/test/setup.ts +++ b/client/src/test/setup.ts @@ -1,5 +1,5 @@ -import { beforeAll, afterEach, vi } from 'vitest'; import { cleanup } from '@testing-library/react'; +import { afterEach, beforeAll, vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; // Mock Tauri API diff --git a/client/src/test/utils.tsx b/client/src/test/utils.tsx index bfe63d0..7b3c5f6 100644 --- a/client/src/test/utils.tsx +++ b/client/src/test/utils.tsx @@ -1,6 +1,6 @@ -import React, { ReactElement } from 'react'; -import { render, RenderOptions } from '@testing-library/react'; -import { vi } from 'vitest'; +import { type RenderOptions, render } from '@testing-library/react'; +import type React from 'react'; +import type { ReactElement } from 'react'; // Custom render function with providers const AllProviders = ({ children }: { children: React.ReactNode }) => { diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 34d7eb8..d518772 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -1,7 +1,9 @@ // Re-export all types -export * from './meeting'; + export * from './annotation'; export * from './audio'; +export * from './events'; +export * from './meeting'; +export * from './preferences'; export * from './server'; export * from './trigger'; -export * from './events'; diff --git a/client/src/types/preferences.ts b/client/src/types/preferences.ts new file mode 100644 index 0000000..4e94580 --- /dev/null +++ b/client/src/types/preferences.ts @@ -0,0 +1,18 @@ +/** + * Preferences types for user settings + */ + +/** Summarization provider options */ +export type SummarizationProvider = 'none' | 'cloud' | 'ollama'; + +/** User preferences */ +export interface UserPreferences { + serverUrl: string; + dataDirectory: string; + encryptionEnabled: boolean; + autoStartEnabled: boolean; + triggerConfidenceThreshold: number; + summarizationProvider: SummarizationProvider; + cloudApiKey: string; + ollamaUrl: string; +} diff --git a/docs/triage.md b/docs/triage.md new file mode 100644 index 0000000..2f06d89 --- /dev/null +++ b/docs/triage.md @@ -0,0 +1,93 @@ +# Uncommitted Changes Review (2025-12-21) + +## Scope +- Reviewed uncommitted changes in Tauri playback/audio, annotations UI/commands, and preferences UI/commands. + +## Resolution Status (Session 3 - 2025-12-21) + +| # | Issue | Status | +|---|-------|--------| +| 1 | Playback position tracking stops after pause/resume | ✅ FIXED | +| 2 | Highlight state sticks on gaps/after seek | ✅ Already correct | +| 3 | Hard-coded 16k sample rate | ✅ Already correct | +| 4 | Sample rate validation | ✅ Already correct | +| 5 | Selecting meeting doesn't stop playback | ✅ Already correct | +| 6 | Audio device IDs unstable | ✅ FIXED | +| 7 | Preferences in-memory only | ✅ FIXED | +| 8 | Annotation UI wired to stubs | ✅ FIXED | + +**Fixes Applied:** +- `playback.rs`: Position tracker now respawns on resume, accumulates samples in state +- `devices.rs`: Device IDs now use stable hash of device name +- `preferences.rs`: Preferences persist to JSON file on disk +- `preferences.rs`: API keys stored securely in system keychain +- `grpc/client.rs`: Annotation methods now make actual gRPC calls via `NoteFlowServiceClient` + +--- + +## Findings & Recommendations + +### 1) Playback position tracking stops after pause/resume (High) ✅ FIXED +Observation: `spawn_position_tracker` exits when `playing_flag` flips false, but `resume_playback` never restarts it, so position/highlight updates stop after the first pause. `pause` flips the flag to false. Evidence: `client/src-tauri/src/commands/playback.rs:146`, `client/src-tauri/src/commands/playback.rs:166`, `client/src-tauri/src/audio/playback.rs:73`. + +Example: user pauses at 00:30, resumes, audio plays but playback position and highlights stop updating. + +Recommendation: keep a single tracker thread alive and gate it on `playback_state`, or re-spawn the tracker inside `resume_playback` when resuming. Also consider syncing position from the playback sink instead of only time math. Evidence: `client/src-tauri/src/commands/playback.rs:146`. + +### 2) Highlight state can stick on gaps or after seek (Medium) ✅ Already correct +Observation: `seek` emits `HIGHLIGHT_CHANGE` only when a segment is found, and the tracker only emits when entering a segment, never clearing on gaps. Evidence: `client/src-tauri/src/commands/playback.rs:83`, `client/src-tauri/src/commands/playback.rs:86`, `client/src-tauri/src/commands/playback.rs:183`. + +Example: seek into silence or between segments and the previous segment remains highlighted indefinitely. + +Recommendation: emit `HIGHLIGHT_CHANGE` with `null` when `find_segment_at_position` returns `None`, and clear when leaving a segment in the tracker loop. Evidence: `client/src-tauri/src/commands/playback.rs:83`. + +### 3) Hard-coded 16k sample rate ignores actual file sample rate (High) ✅ Already correct +Observation: `load_audio_file` reads the sample rate, but `select_meeting` ignores it and playback uses `DEFAULT_SAMPLE_RATE` for both audio and position tracking. Evidence: `client/src-tauri/src/audio/loader.rs:40`, `client/src-tauri/src/commands/meeting.rs:147`, `client/src-tauri/src/commands/playback.rs:123`, `client/src-tauri/src/commands/playback.rs:160`. + +Example: a 48kHz recording will play at ~3x speed and the UI highlight will drift from the audio. + +Recommendation: store `sample_rate` in `AppState` when loading audio, pass it into `AudioPlayback::play_buffer`, and use it for the tracker loop. Fallback to 16k only when the value is missing. Evidence: `client/src-tauri/src/commands/meeting.rs:147`. + +### 4) Missing validation for `sample_rate` can infinite-loop or divide by zero (Medium) ✅ Already correct +Observation: `samples_to_chunks` computes `chunk_samples` from `sample_rate` and loops until offset advances; if `sample_rate` is 0 (or extremely small), `chunk_samples` becomes 0 and the loop never progresses. `play_buffer` also divides by `sample_rate`. Evidence: `client/src-tauri/src/audio/loader.rs:88`, `client/src-tauri/src/audio/loader.rs:92`, `client/src-tauri/src/audio/playback.rs:61`. + +Example: a corrupted audio file with `sample_rate = 0` will hang the loader or produce invalid duration math. + +Recommendation: validate `sample_rate > 0` and `chunk_samples >= 1` in `load_audio_file`, returning a clear error for invalid files. Guard divisions in playback accordingly. Evidence: `client/src-tauri/src/audio/loader.rs:40`. + +### 5) Selecting a meeting doesn't stop active playback; stale position when audio missing (Medium) ✅ Already correct +Observation: `select_meeting` only flips `playback_state` to `Stopped`; it never calls `AudioPlayback::stop` or clears the playback handle. When no audio is found, it clears duration but doesn’t reset `playback_position`. Evidence: `client/src-tauri/src/commands/meeting.rs:90`, `client/src-tauri/src/commands/meeting.rs:128`. + +Example: switching meetings mid-playback can continue the old audio; selecting a meeting with no audio leaves the previous playback position in the UI. + +Recommendation: reuse the stop logic (or shared helper) to stop playback and clear highlight/position when changing meetings; explicitly reset `playback_position` in the no-audio path. Evidence: `client/src-tauri/src/commands/playback.rs:53`. + +### 6) Audio device IDs are unstable across runs (Medium) ✅ FIXED +Observation: device IDs are assigned from enumeration order, and `get_default_input_device` always returns `id = 0`. Preferences store that id and later match by id. Evidence: `client/src-tauri/src/audio/devices.rs:16`, `client/src-tauri/src/audio/devices.rs:57`, `client/src-tauri/src/commands/audio.rs:22`, `client/src-tauri/src/commands/audio.rs:42`. + +Example: unplugging/replugging devices changes enumeration order; the stored id may point to a different mic next launch. + +Recommendation: persist a stable identifier (device name + host, or a hashed name), and resolve by that; handle duplicate names gracefully. Evidence: `client/src-tauri/src/audio/devices.rs:21`. + +### 7) Preferences are in-memory only and include sensitive fields (Low/Medium) ✅ FIXED +Observation: preferences are stored in an in-memory `HashMap`; there’s no persistence or secure storage, even for API keys. The UI stores and loads these values. Evidence: `client/src-tauri/src/state/app_state.rs:262`, `client/src-tauri/src/commands/preferences.rs:120`, `client/src/components/settings/SettingsPanel.tsx:167`. + +Example: restarting the app loses `serverUrl`, `dataDirectory`, and `cloudApiKey`; API keys are kept in plain memory and re-exposed to the UI. + +Recommendation: persist preferences to disk (config file) and store secrets in the OS keychain/credential vault; avoid returning stored secrets to the UI unless explicitly requested. Evidence: `client/src-tauri/src/commands/preferences.rs:151`. + +### 8) Annotation UI wired to stubbed gRPC methods (Medium) ✅ FIXED +Observation: the new UI calls annotation add/delete, but Rust gRPC client methods were TODO/NotImplemented or returned empty lists. + +**Fix Applied:** Replaced all 5 annotation stub methods in `grpc/client.rs` with actual gRPC calls: +- Added `tonic_client()` helper to create `NoteFlowServiceClient` from existing `Channel` +- Added `annotation_from_proto()` converter for proto → local type mapping +- Added `impl From for AnnotationType` in `types.rs` +- `add_annotation`, `get_annotation`, `list_annotations`, `update_annotation`, `delete_annotation` now make real server calls +- Removed dead `AnnotationInfo::new()` constructor (no longer needed) + +## Suggested Tests +- Playback pause/resume keeps position/highlight updates flowing (unit/integration around playback events). +- Playback speed/duration is correct for a 48kHz `.nfaudio` fixture. +- `select_meeting` stops audio and resets position when switching meetings or when audio is missing. +- Device selection resolves the intended microphone across restarts. diff --git a/docs/ui.md b/docs/ui.md index 66b0a6f..4fbdb87 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,18 +1,21 @@ # NoteFlow: Tauri Migration Master Plan -This document serves as the **authoritative technical specification** for migrating NoteFlow from a Python/Flet architecture to a high-performance **Rust/Tauri** application. +> **STATUS: ✅ MIGRATION COMPLETE (2025-12-21)** +> The Python/Flet client has been fully replaced by the Tauri + React client. +> The old `src/noteflow/client/` directory has been removed. -**Scope:** This specification covers the **CLIENT-SIDE** migration only: +This document serves as the **historical technical specification** for the migration from Python/Flet to Rust/Tauri. -- ✅ **REPLACING:** Python/Flet client (`src/noteflow/client/`) → Tauri (Rust + React) -- ✅ **NEW DIRECTORY:** `client/` (separate from existing codebase) -- ❌ **NOT REPLACING:** Python gRPC server (`src/noteflow/grpc/server.py`) -- ❌ **NOT REPLACING:** Python domain/application/infrastructure layers -- ❌ **NOT MODIFYING:** Existing `src/noteflow/` directory +**Scope:** This specification covered the **CLIENT-SIDE** migration: + +- ✅ **REPLACED:** Python/Flet client (`src/noteflow/client/`) → Tauri (Rust + React) +- ✅ **COMPLETED:** `client/` directory with full feature parity +- ✅ **OLD CLIENT REMOVED:** `src/noteflow/client/` deleted +- ❌ **NOT REPLACED:** Python gRPC server (`src/noteflow/grpc/server.py`) +- ❌ **NOT REPLACED:** Python domain/application/infrastructure layers The Tauri client connects to the **existing Python gRPC server** via `tonic`. All server-side logic (ASR, diarization, summarization, persistence) remains in Python. ---- ## Table of Contents @@ -9026,5 +9029,4 @@ client/ **Document Version:** 1.0.0 **Last Updated:** December 2024 -**Total Lines of Specification:** ~8000 - +**Total Lines of Specification:** ~8000 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b1aa059..4b934fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,7 @@ requires-python = ">=3.12" dependencies = [ # Core "pydantic>=2.0", - # Spike 1: UI + Tray + Hotkeys - "flet[all]>=0.21", - "pystray>=0.19", - "pillow>=10.0", - "pynput>=1.7", - # Spike 2: Audio + # Audio "sounddevice>=0.4.6", "numpy>=1.26", # Spike 3: ASR diff --git a/src/noteflow/client/__init__.py b/src/noteflow/client/__init__.py deleted file mode 100644 index 9d379bd..0000000 --- a/src/noteflow/client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""NoteFlow client application.""" diff --git a/src/noteflow/client/_trigger_mixin.py b/src/noteflow/client/_trigger_mixin.py deleted file mode 100644 index 8316d77..0000000 --- a/src/noteflow/client/_trigger_mixin.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Trigger detection mixin for NoteFlow client. - -Extracts trigger detection logic from app.py to keep file under 750 lines. -Handles meeting detection triggers via app audio activity and calendar proximity. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING, Protocol - -import flet as ft - -from noteflow.application.services import TriggerService, TriggerServiceSettings -from noteflow.config.settings import TriggerSettings, get_trigger_settings -from noteflow.domain.triggers import TriggerAction, TriggerDecision -from noteflow.infrastructure.triggers import ( - AppAudioProvider, - AppAudioSettings, - CalendarProvider, - CalendarSettings, -) -from noteflow.infrastructure.triggers.calendar import parse_calendar_events - -if TYPE_CHECKING: - from noteflow.client.state import AppState - -logger = logging.getLogger(__name__) - - -class TriggerHost(Protocol): - """Protocol for app hosting trigger mixin.""" - - _state: AppState - _trigger_settings: TriggerSettings | None - _trigger_service: TriggerService | None - _app_audio: AppAudioProvider | None - _calendar_provider: CalendarProvider | None - _trigger_poll_interval: float - _trigger_task: asyncio.Task | None - - def _start_recording(self) -> None: - """Start recording audio.""" - ... - - def _ensure_audio_capture(self) -> bool: - """Ensure audio capture is running.""" - ... - - -class TriggerMixin: - """Mixin providing trigger detection functionality. - - Requires host to implement TriggerHost protocol. - """ - - def _initialize_triggers(self: TriggerHost) -> None: - """Initialize trigger settings, providers, and service.""" - self._trigger_settings = get_trigger_settings() - self._state.trigger_enabled = self._trigger_settings.trigger_enabled - self._trigger_poll_interval = self._trigger_settings.trigger_poll_interval_seconds - meeting_apps = {app.lower() for app in self._trigger_settings.trigger_meeting_apps} - suppressed_apps = {app.lower() for app in self._trigger_settings.trigger_suppressed_apps} - - app_audio_settings = AppAudioSettings( - enabled=self._trigger_settings.trigger_audio_enabled, - threshold_db=self._trigger_settings.trigger_audio_threshold_db, - window_seconds=self._trigger_settings.trigger_audio_window_seconds, - min_active_ratio=self._trigger_settings.trigger_audio_min_active_ratio, - min_samples=self._trigger_settings.trigger_audio_min_samples, - max_history=self._trigger_settings.trigger_audio_max_history, - weight=self._trigger_settings.trigger_weight_audio, - meeting_apps=meeting_apps, - suppressed_apps=suppressed_apps, - ) - calendar_settings = CalendarSettings( - enabled=self._trigger_settings.trigger_calendar_enabled, - weight=self._trigger_settings.trigger_weight_calendar, - lookahead_minutes=self._trigger_settings.trigger_calendar_lookahead_minutes, - lookbehind_minutes=self._trigger_settings.trigger_calendar_lookbehind_minutes, - events=parse_calendar_events(self._trigger_settings.trigger_calendar_events), - ) - - self._app_audio = AppAudioProvider(app_audio_settings) - self._calendar_provider = CalendarProvider(calendar_settings) - self._trigger_service = TriggerService( - providers=[self._app_audio, self._calendar_provider], - settings=TriggerServiceSettings( - enabled=self._trigger_settings.trigger_enabled, - auto_start_enabled=self._trigger_settings.trigger_auto_start, - rate_limit_seconds=self._trigger_settings.trigger_rate_limit_minutes * 60, - snooze_seconds=self._trigger_settings.trigger_snooze_minutes * 60, - threshold_ignore=self._trigger_settings.trigger_confidence_ignore, - threshold_auto_start=self._trigger_settings.trigger_confidence_auto, - ), - ) - - def _should_keep_capture_running(self: TriggerHost) -> bool: - """Return True if background audio capture should remain active.""" - return False - - async def _trigger_check_loop(self: TriggerHost) -> None: - """Background loop to check trigger conditions. - - Runs every poll interval while not recording. - """ - check_interval = self._trigger_poll_interval - try: - while True: - await asyncio.sleep(check_interval) - - # Skip if recording or trigger pending - if self._state.recording or self._state.trigger_pending: - continue - - # Skip if triggers disabled - if not self._state.trigger_enabled or not self._trigger_service: - continue - - # Evaluate triggers - decision = self._trigger_service.evaluate() - self._state.trigger_decision = decision - - if decision.action == TriggerAction.IGNORE: - continue - - if decision.action == TriggerAction.AUTO_START: - # Auto-start if connected - if self._state.connected: - logger.info( - "Auto-starting recording (confidence=%.2f)", decision.confidence - ) - self._start_recording() - elif decision.action == TriggerAction.NOTIFY: - # Show prompt to user - self._show_trigger_prompt(decision) - except asyncio.CancelledError: - logger.debug("Trigger loop cancelled") - raise - - def _show_trigger_prompt(self: TriggerHost, decision: TriggerDecision) -> None: - """Show trigger notification prompt to user. - - Args: - decision: Trigger decision with confidence and signals. - """ - self._state.trigger_pending = True - - # Build signal description - signal_desc = ", ".join(s.app_name or s.source.value for s in decision.signals) - - def handle_start(_: ft.ControlEvent) -> None: - self._state.trigger_pending = False - if dialog.open: - dialog.open = False - self._state.request_update() - if self._state.connected: - self._start_recording() - - def handle_snooze(_: ft.ControlEvent) -> None: - self._state.trigger_pending = False - if self._trigger_service: - self._trigger_service.snooze() - if dialog.open: - dialog.open = False - self._state.request_update() - - def handle_dismiss(_: ft.ControlEvent) -> None: - self._state.trigger_pending = False - if dialog.open: - dialog.open = False - self._state.request_update() - - dialog = ft.AlertDialog( - title=ft.Text("Meeting Detected"), - content=ft.Text( - "Detected: " - f"{signal_desc}\n" - f"Confidence: {decision.confidence:.0%}\n\n" - "Start recording?" - ), - actions=[ - ft.TextButton("Start", on_click=handle_start), - ft.TextButton("Snooze", on_click=handle_snooze), - ft.TextButton("Dismiss", on_click=handle_dismiss), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - if self._state._page: - self._state._page.dialog = dialog - dialog.open = True - self._state.request_update() diff --git a/src/noteflow/client/app.py b/src/noteflow/client/app.py deleted file mode 100644 index 66ae186..0000000 --- a/src/noteflow/client/app.py +++ /dev/null @@ -1,799 +0,0 @@ -"""NoteFlow Flet client application. - -Captures audio locally and streams to NoteFlow gRPC server for transcription. -Orchestrates UI components - does not contain component logic. -""" - -from __future__ import annotations - -import argparse -import asyncio -import logging -import queue -import threading -import time -from typing import TYPE_CHECKING, Final - -import flet as ft - -from noteflow.application.services import TriggerService -from noteflow.client._trigger_mixin import TriggerMixin -from noteflow.client.components import ( - AnnotationDisplayComponent, - AnnotationToolbarComponent, - ConnectionPanelComponent, - MeetingLibraryComponent, - PlaybackControlsComponent, - PlaybackSyncController, - RecordingTimerComponent, - SummaryPanelComponent, - TranscriptComponent, - VuMeterComponent, -) -from noteflow.client.state import AppState -from noteflow.config.constants import DEFAULT_SAMPLE_RATE -from noteflow.config.settings import TriggerSettings, get_settings -from noteflow.infrastructure.audio import ( - MeetingAudioReader, - PlaybackState, - SoundDeviceCapture, - TimestampedAudio, -) -from noteflow.infrastructure.security import AesGcmCryptoBox, KeyringKeyStore -from noteflow.infrastructure.summarization import create_summarization_service - -if TYPE_CHECKING: - import numpy as np - from numpy.typing import NDArray - - from noteflow.application.services.summarization_service import SummarizationService - from noteflow.grpc.client import ( - AnnotationInfo, - MeetingInfo, - NoteFlowClient, - ServerInfo, - TranscriptSegment, - ) - from noteflow.infrastructure.triggers import AppAudioProvider, CalendarProvider - -logger = logging.getLogger(__name__) - -DEFAULT_SERVER: Final[str] = "localhost:50051" - - -class NoteFlowClientApp(TriggerMixin): - """Flet client application for NoteFlow. - - Orchestrates UI components and recording logic. - Inherits trigger detection from TriggerMixin. - """ - - def __init__(self, server_address: str = DEFAULT_SERVER) -> None: - """Initialize the app. - - Args: - server_address: NoteFlow server address. - """ - # Centralized state - self._state = AppState(server_address=server_address) - - # Audio capture (REUSE existing SoundDeviceCapture) - self._audio_capture: SoundDeviceCapture | None = None - - # Client reference (managed by ConnectionPanelComponent) - self._client: NoteFlowClient | None = None - - # UI components (initialized in _build_ui) - self._connection_panel: ConnectionPanelComponent | None = None - self._vu_meter: VuMeterComponent | None = None - self._timer: RecordingTimerComponent | None = None - self._transcript: TranscriptComponent | None = None - self._playback_controls: PlaybackControlsComponent | None = None - self._sync_controller: PlaybackSyncController | None = None - self._annotation_toolbar: AnnotationToolbarComponent | None = None - - # Meeting library (M4) - self._meeting_library: MeetingLibraryComponent | None = None - - # Summarization (M6) - self._summarization_service: SummarizationService | None = None - self._summary_panel: SummaryPanelComponent | None = None - - # Annotation display for review mode (M4) - self._annotation_display: AnnotationDisplayComponent | None = None - - # Audio reader for archived meetings (M4) - self._audio_reader: MeetingAudioReader | None = None - - # Trigger detection (M5) - self._trigger_settings: TriggerSettings | None = None - self._trigger_service: TriggerService | None = None - self._app_audio: AppAudioProvider | None = None - self._calendar_provider: CalendarProvider | None = None - self._trigger_poll_interval: float = 0.0 - self._trigger_task: asyncio.Task | None = None - - # Recording buttons - self._record_btn: ft.ElevatedButton | None = None - self._stop_btn: ft.ElevatedButton | None = None - - # Audio frame consumer thread (process frames from audio callback thread) - self._audio_frame_queue: queue.Queue[tuple[NDArray[np.float32], float]] = queue.Queue() - self._audio_consumer_stop = threading.Event() - self._audio_consumer_thread: threading.Thread | None = None - - def run(self) -> None: - """Run the Flet application.""" - ft.app(target=self._main) - - def _main(self, page: ft.Page) -> None: - """Flet app entry point. - - Args: - page: Flet page. - """ - self._state.set_page(page) - page.title = "NoteFlow Client" - page.window.width = 800 - page.window.height = 600 - page.padding = 20 - - page.add(self._build_ui()) - page.update() - - # Initialize trigger detection (M5) - self._initialize_triggers() - - # Start trigger check loop if enabled (opt-in via settings) - if self._state.trigger_enabled: - self._trigger_task = page.run_task(self._trigger_check_loop) - - # Ensure background tasks are cancelled when the UI closes - page.on_disconnect = lambda _e: self._shutdown() - - def _build_ui(self) -> ft.Column: - """Build the main UI by composing components. - - Returns: - Main UI column. - """ - # Create components with state - self._connection_panel = ConnectionPanelComponent( - state=self._state, - on_connected=self._on_connected, - on_disconnected=self._on_disconnected, - on_transcript_callback=self._on_transcript, - on_connection_change_callback=self._on_connection_change, - ) - self._vu_meter = VuMeterComponent(state=self._state) - self._timer = RecordingTimerComponent(state=self._state) - - # Transcript with click handler for playback sync - self._transcript = TranscriptComponent( - state=self._state, - on_segment_click=self._on_segment_click, - ) - - # Playback controls and sync - self._playback_controls = PlaybackControlsComponent( - state=self._state, - on_position_change=self._on_playback_position_change, - ) - self._sync_controller = PlaybackSyncController( - state=self._state, - on_highlight_change=self._on_highlight_change, - ) - - # Annotation toolbar - self._annotation_toolbar = AnnotationToolbarComponent( - state=self._state, - get_client=lambda: self._client, - ) - - # Annotation display for review mode - self._annotation_display = AnnotationDisplayComponent( - state=self._state, - on_annotation_seek=self._on_annotation_seek, - ) - - # Meeting library (M4) - self._meeting_library = MeetingLibraryComponent( - state=self._state, - get_client=lambda: self._client, - on_meeting_selected=self._on_meeting_selected, - ) - - # Initialize summarization service - auto-detects LOCAL/MOCK providers - self._summarization_service = create_summarization_service() - - # Summary panel - self._summary_panel = SummaryPanelComponent( - state=self._state, - get_service=lambda: self._summarization_service, - on_citation_click=self._on_citation_click, - ) - - # Recording controls (still in app.py - orchestration) - self._record_btn = ft.ElevatedButton( - "Start Recording", - on_click=self._on_record_click, - icon=ft.Icons.MIC, - disabled=True, - ) - self._stop_btn = ft.ElevatedButton( - "Stop", - on_click=self._on_stop_click, - icon=ft.Icons.STOP, - disabled=True, - ) - - recording_row = ft.Row([self._record_btn, self._stop_btn]) - - # Main layout - compose component builds - return ft.Column( - [ - ft.Text("NoteFlow Client", size=24, weight=ft.FontWeight.BOLD), - ft.Divider(), - self._connection_panel.build(), - ft.Divider(), - recording_row, - self._vu_meter.build(), - self._timer.build(), - self._annotation_toolbar.build(), - self._annotation_display.build(), - ft.Divider(), - ft.Text("Transcript:", size=16, weight=ft.FontWeight.BOLD), - self._transcript.build(), - self._playback_controls.build(), - ft.Divider(), - self._summary_panel.build(), - ft.Divider(), - ft.Text("Meeting Library:", size=16, weight=ft.FontWeight.BOLD), - self._meeting_library.build(), - ], - spacing=10, - ) - - def _ensure_audio_reader(self) -> MeetingAudioReader | None: - """Lazily initialize MeetingAudioReader (for review playback).""" - if self._audio_reader: - return self._audio_reader - - try: - settings = get_settings() - keystore = KeyringKeyStore() - crypto = AesGcmCryptoBox(keystore) - self._audio_reader = MeetingAudioReader(crypto, settings.meetings_dir) - except (OSError, ValueError, KeyError, RuntimeError) as exc: - logger.exception("Failed to initialize meeting audio reader: %s", exc) - self._audio_reader = None - - return self._audio_reader - - def _load_meeting_audio(self, meeting: MeetingInfo) -> list[TimestampedAudio]: - """Load archived audio for a meeting, if available.""" - reader = self._ensure_audio_reader() - if not reader: - return [] - - try: - if not reader.audio_exists(meeting.id): - logger.info("No archived audio for meeting %s", meeting.id) - return [] - return reader.load_meeting_audio(meeting.id) - except FileNotFoundError: - logger.info("Audio file missing for meeting %s", meeting.id) - return [] - except (OSError, ValueError, RuntimeError) as exc: - logger.exception("Failed to load audio for meeting %s: %s", meeting.id, exc) - return [] - - def _ensure_audio_capture(self) -> bool: - """Start audio capture if needed. - - Returns: - True if audio capture is running, False if start failed. - """ - if self._audio_capture: - return True - - try: - self._audio_capture = SoundDeviceCapture() - self._audio_capture.start( - device_id=None, - on_frames=self._on_audio_frames, - sample_rate=DEFAULT_SAMPLE_RATE, - channels=1, - chunk_duration_ms=100, - ) - except (RuntimeError, OSError) as exc: - logger.exception("Failed to start audio capture: %s", exc) - self._audio_capture = None - return False - - return True - - def _on_connected(self, client: NoteFlowClient, info: ServerInfo) -> None: - """Handle successful connection. - - Args: - client: Connected NoteFlowClient. - info: Server info. - """ - self._client = client - if self._transcript: - self._transcript.display_server_info(info) - if ( - self._state.recording - and self._state.current_meeting - and not self._client.start_streaming(self._state.current_meeting.id) - ): - logger.error("Failed to resume streaming after reconnect") - self._stop_recording() - self._update_recording_buttons() - - # Refresh meeting library on connection - if self._meeting_library: - self._meeting_library.refresh_meetings() - - def _on_disconnected(self) -> None: - """Handle disconnection.""" - self._shutdown() - if self._state.recording: - self._stop_recording() - self._client = None - self._update_recording_buttons() - - def _on_connection_change(self, _connected: bool, _message: str) -> None: - """Handle connection state change from client. - - Args: - connected: Connection state. - message: Status message. - """ - self._update_recording_buttons() - - def _on_transcript(self, segment: TranscriptSegment) -> None: - """Handle transcript update callback. - - Args: - segment: Transcript segment from server. - """ - if self._transcript: - self._transcript.add_segment(segment) - self._ensure_summary_panel_ready() - - def _on_record_click(self, e: ft.ControlEvent) -> None: - """Handle record button click. - - Args: - e: Control event. - """ - self._start_recording() - - def _on_stop_click(self, e: ft.ControlEvent) -> None: - """Handle stop button click. - - Args: - e: Control event. - """ - self._stop_recording() - - def _start_recording(self) -> None: - """Start recording audio.""" - if not self._client or not self._state.connected: - return - - # Create meeting - meeting = self._client.create_meeting(title=f"Recording {time.strftime('%Y-%m-%d %H:%M')}") - if not meeting: - logger.error("Failed to create meeting") - return - - self._state.current_meeting = meeting - - # Make summary panel visible once we have meeting context - self._ensure_summary_panel_ready() - - # Start streaming - if not self._client.start_streaming(meeting.id): - logger.error("Failed to start streaming") - self._client.stop_meeting(meeting.id) - self._state.current_meeting = None - return - - # Start audio capture (reuse existing capture if already running) - if not self._ensure_audio_capture(): - self._client.stop_streaming() - self._client.stop_meeting(meeting.id) - self._state.reset_recording_state() - self._update_recording_buttons() - return - - self._state.recording = True - - # Start audio frame consumer thread - self._start_audio_consumer() - - # Clear audio buffer for new recording - self._state.session_audio_buffer.clear() - - # Start timer - if self._timer: - self._timer.start() - - # Clear transcript - if self._transcript: - self._transcript.clear() - - # Enable annotation toolbar - if self._annotation_toolbar: - self._annotation_toolbar.set_visible(True) - self._annotation_toolbar.set_enabled(True) - - self._update_recording_buttons() - - def _stop_recording(self) -> None: - """Stop recording audio.""" - # Guard against multiple stop attempts - if not self._state.recording: - return - - # Immediately update state to prevent re-entry - self._state.recording = False - if self._stop_btn: - self._stop_btn.disabled = True - - # Stop timer immediately to provide visual feedback - if self._timer: - self._timer.stop() - - self._update_recording_buttons() - - # Stop audio frame consumer thread - self._stop_audio_consumer() - - # Stop audio capture - if self._audio_capture and not self._should_keep_capture_running(): - self._audio_capture.stop() - self._audio_capture = None - - # Stop streaming - if self._client: - self._client.stop_streaming() - - # Stop meeting - if self._state.current_meeting: - self._client.stop_meeting(self._state.current_meeting.id) - - # Load buffered audio for playback - if self._state.session_audio_buffer and self._playback_controls: - self._playback_controls.load_audio() - self._playback_controls.set_visible(True) - - # Start sync controller for playback - if self._sync_controller: - self._sync_controller.start() - - # Keep annotation toolbar visible for playback annotations - if self._annotation_toolbar: - self._annotation_toolbar.set_enabled(True) - - # Ensure summary panel reflects current data after recording ends - self._ensure_summary_panel_ready() - - def _on_audio_frames( - self, - frames: NDArray[np.float32], - timestamp: float, - ) -> None: - """Handle audio frames from capture (called from audio thread). - - Enqueues frames for processing by consumer thread to avoid blocking - the real-time audio callback. - - Args: - frames: Audio samples. - timestamp: Capture timestamp. - """ - self._audio_frame_queue.put_nowait((frames.copy(), timestamp)) - - def _start_audio_consumer(self) -> None: - """Start the audio frame consumer thread.""" - if self._audio_consumer_thread is not None and self._audio_consumer_thread.is_alive(): - return - self._audio_consumer_stop.clear() - self._audio_consumer_thread = threading.Thread( - target=self._audio_consumer_loop, - daemon=True, - name="audio-consumer", - ) - self._audio_consumer_thread.start() - - def _stop_audio_consumer(self) -> None: - """Stop the audio frame consumer thread.""" - self._audio_consumer_stop.set() - if self._audio_consumer_thread is not None: - self._audio_consumer_thread.join(timeout=1.0) - # Only clear reference if thread exited cleanly - if self._audio_consumer_thread.is_alive(): - logger.warning( - "Audio consumer thread did not exit within timeout, keeping reference" - ) - else: - self._audio_consumer_thread = None - # Drain remaining frames - while not self._audio_frame_queue.empty(): - try: - self._audio_frame_queue.get_nowait() - except queue.Empty: - break - - def _audio_consumer_loop(self) -> None: - """Consumer loop that processes audio frames from the queue.""" - while not self._audio_consumer_stop.is_set(): - try: - frames, timestamp = self._audio_frame_queue.get(timeout=0.1) - self._process_audio_frames(frames, timestamp) - except queue.Empty: - continue - - def _process_audio_frames( - self, - frames: NDArray[np.float32], - timestamp: float, - ) -> None: - """Process audio frames from consumer thread. - - Args: - frames: Audio samples. - timestamp: Capture timestamp. - """ - # Send to server - if self._client and self._state.recording: - self._client.send_audio(frames, timestamp) - - # Buffer for playback - if self._state.recording: - duration = len(frames) / DEFAULT_SAMPLE_RATE - self._state.session_audio_buffer.append( - TimestampedAudio(frames=frames, timestamp=timestamp, duration=duration) - ) - - # Update VU meter - if self._vu_meter: - self._vu_meter.on_audio_frames(frames) - - # Trigger detection uses system output + calendar; no mic-derived updates here. - - def _on_segment_click(self, segment_index: int) -> None: - """Handle transcript segment click - seek playback to segment. - - Args: - segment_index: Index of clicked segment. - """ - if self._sync_controller: - self._sync_controller.seek_to_segment(segment_index) - - def _on_citation_click(self, segment_id: int) -> None: - """Handle citation chip click - seek to segment by segment_id. - - Args: - segment_id: Segment ID from citation. - """ - # Find segment index by segment_id - for idx, seg in enumerate(self._state.transcript_segments): - if seg.segment_id == segment_id: - self._on_segment_click(idx) - break - - def _on_annotation_seek(self, timestamp: float) -> None: - """Handle annotation click - seek to timestamp. - - Args: - timestamp: Timestamp in seconds to seek to. - """ - if self._playback_controls: - self._playback_controls.seek(timestamp) - - def _on_meeting_selected(self, meeting: MeetingInfo) -> None: - """Handle meeting selection from library. - - Loads transcript segments, annotations, and prepares for playback review. - - Args: - meeting: Selected meeting info. - """ - if not self._client: - return - - # 1. Stop any existing playback - if self._state.playback.state != PlaybackState.STOPPED: - self._state.playback.stop() - if self._sync_controller: - self._sync_controller.stop() - - # Capture client reference for closure (may run in background thread) - client = self._client - - def load_and_apply() -> None: - if not client: - return - try: - segments = client.get_meeting_segments(meeting.id) - annotations = client.list_annotations(meeting.id) - audio_chunks = self._load_meeting_audio(meeting) - except (ConnectionError, ValueError, OSError, RuntimeError) as exc: - logger.exception("Failed to load meeting %s: %s", meeting.id, exc) - return - - # Apply results on UI thread to avoid race conditions - self._state.run_on_ui_thread( - lambda: self._apply_meeting_data(meeting, segments, annotations, audio_chunks) - ) - - page = self._state._page - if page and hasattr(page, "run_thread"): - page.run_thread(load_and_apply) - else: - load_and_apply() - - def _apply_meeting_data( - self, - meeting: MeetingInfo, - segments: list[TranscriptSegment], - annotations: list[AnnotationInfo], - audio_chunks: list[TimestampedAudio], - ) -> None: - """Apply loaded meeting data to state and UI (UI thread only).""" - # Clear state and UI before populating with fresh data - self._state.clear_transcript() - self._state.annotations.clear() - self._state.current_summary = None - self._state.highlighted_segment_index = None - self._state.clear_session_audio() - - if self._transcript: - self._transcript.clear() - if self._annotation_display: - self._annotation_display.clear() - - # Populate transcript - if self._transcript: - for segment in segments: - self._transcript.add_segment(segment) - - # Populate annotations - self._state.annotations = annotations - if self._annotation_display: - self._annotation_display.load_annotations(annotations) - - # Update meeting state - self._state.current_meeting = meeting - self._state.selected_meeting = meeting - - # Enable annotation toolbar for adding new annotations - if self._annotation_toolbar: - self._annotation_toolbar.set_visible(True) - self._annotation_toolbar.set_enabled(True) - - # Load audio for playback if available - if audio_chunks: - self._state.session_audio_buffer = audio_chunks - if self._playback_controls: - self._playback_controls.load_audio() - self._playback_controls.set_visible(True) - else: - # Hide controls when no audio is available - if self._playback_controls: - self._playback_controls.set_visible(False) - self._state.playback.stop() - self._state.playback_position = 0.0 - - # Update summary panel visibility/enabled state - self._ensure_summary_panel_ready() - - # Start sync controller for playback highlighting - if self._sync_controller: - self._sync_controller.start() - - logger.info( - "Loaded meeting: %s (%d segments, %d annotations, %d audio chunks)", - meeting.title, - len(segments), - len(annotations), - len(audio_chunks), - ) - - def _ensure_summary_panel_ready(self) -> None: - """Update summary panel visibility/enabled state based on data availability.""" - if not self._summary_panel: - return - - has_meeting = self._state.current_meeting is not None - has_segments = bool(self._state.transcript_segments) - - # Visible once there is a meeting context; enabled when segments exist. - self._summary_panel.set_visible(has_meeting or has_segments) - self._summary_panel.set_enabled(has_segments and not self._state.summary_loading) - - def _on_highlight_change(self, index: int | None) -> None: - """Handle highlight change from sync controller. - - Args: - index: Segment index to highlight, or None to clear. - """ - if self._transcript: - self._transcript.update_highlight(index) - - def _on_playback_position_change(self, position: float) -> None: - """Handle playback position change. - - Args: - position: Current playback position in seconds. - """ - # Sync controller handles segment matching internally - _ = position # Position tracked in state - - def _shutdown(self) -> None: - """Stop background tasks and capture started for triggers.""" - if self._trigger_task: - self._trigger_task.cancel() - self._trigger_task = None - - # Stop audio consumer if running - self._stop_audio_consumer() - - if self._app_audio: - self._app_audio.close() - - if self._audio_capture and not self._state.recording: - try: - self._audio_capture.stop() - except RuntimeError: - logger.debug("Error stopping audio capture during shutdown", exc_info=True) - self._audio_capture = None - - def _update_recording_buttons(self) -> None: - """Update recording button states.""" - if self._record_btn: - self._record_btn.disabled = not self._state.connected or self._state.recording - - if self._stop_btn: - self._stop_btn.disabled = not self._state.recording - - self._state.request_update() - - -def main() -> None: - """Run the NoteFlow client application.""" - parser = argparse.ArgumentParser(description="NoteFlow Client") - parser.add_argument( - "-s", - "--server", - type=str, - default=DEFAULT_SERVER, - help=f"Server address (default: {DEFAULT_SERVER})", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Enable verbose logging", - ) - args = parser.parse_args() - - # Configure logging - log_level = logging.DEBUG if args.verbose else logging.INFO - logging.basicConfig( - level=log_level, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - ) - - # Run app - app = NoteFlowClientApp(server_address=args.server) - app.run() - - -if __name__ == "__main__": - main() diff --git a/src/noteflow/client/components/__init__.py b/src/noteflow/client/components/__init__.py deleted file mode 100644 index 7cc39bd..0000000 --- a/src/noteflow/client/components/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""UI components for NoteFlow client. - -All components use existing types and utilities - no recreation. -""" - -from noteflow.client.components._async_mixin import AsyncOperationMixin -from noteflow.client.components._thread_mixin import BackgroundWorkerMixin -from noteflow.client.components.annotation_display import AnnotationDisplayComponent -from noteflow.client.components.annotation_toolbar import AnnotationToolbarComponent -from noteflow.client.components.connection_panel import ConnectionPanelComponent -from noteflow.client.components.meeting_library import MeetingLibraryComponent -from noteflow.client.components.playback_controls import PlaybackControlsComponent -from noteflow.client.components.playback_sync import PlaybackSyncController -from noteflow.client.components.recording_timer import RecordingTimerComponent -from noteflow.client.components.summary_panel import SummaryPanelComponent -from noteflow.client.components.transcript import TranscriptComponent -from noteflow.client.components.vu_meter import VuMeterComponent - -__all__ = [ - "AnnotationDisplayComponent", - "AnnotationToolbarComponent", - "AsyncOperationMixin", - "BackgroundWorkerMixin", - "ConnectionPanelComponent", - "MeetingLibraryComponent", - "PlaybackControlsComponent", - "PlaybackSyncController", - "RecordingTimerComponent", - "SummaryPanelComponent", - "TranscriptComponent", - "VuMeterComponent", -] diff --git a/src/noteflow/client/components/_async_mixin.py b/src/noteflow/client/components/_async_mixin.py deleted file mode 100644 index 46d8b08..0000000 --- a/src/noteflow/client/components/_async_mixin.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Mixin for async operations with loading/error state management. - -Provides standardized handling for UI components that perform async operations, -including loading state, error handling, and UI thread dispatch. -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, TypeVar - -if TYPE_CHECKING: - import flet as ft - - -T = TypeVar("T") - - -class AsyncOperationMixin[T]: - """Mixin providing standardized async operation handling. - - Manages loading state, error handling, and UI thread dispatch for - Flet components that perform async operations. - - Components using this mixin must have: - - `_page: ft.Page | None` attribute for UI updates - """ - - _page: ft.Page | None - - async def run_async_operation( - self, - operation: Callable[[], Awaitable[T]], - on_success: Callable[[T], None], - on_error: Callable[[str], None], - set_loading: Callable[[bool], None], - ) -> T | None: - """Run async operation with standardized state management. - - Handles loading state, error catching, and UI thread dispatch. - All callbacks are dispatched to the UI thread. - - Args: - operation: Async callable to execute. - on_success: Callback with result on success (called on UI thread). - on_error: Callback with error message on failure (called on UI thread). - set_loading: Callback to set loading state (called on UI thread). - - Returns: - Result of operation on success, None on failure. - """ - self._dispatch_ui(lambda: set_loading(True)) - try: - result = await operation() - # Capture result for closure - self._dispatch_ui(lambda r=result: on_success(r)) # type: ignore[misc] - return result - except Exception as e: - error_msg = str(e) - self._dispatch_ui(lambda msg=error_msg: on_error(msg)) # type: ignore[misc] - return None - finally: - self._dispatch_ui(lambda: set_loading(False)) - - def _dispatch_ui(self, callback: Callable[[], None]) -> None: - """Dispatch callback to UI thread. - - Safe to call even if page is None (no-op in that case). - - Args: - callback: Function to execute on UI thread. - """ - if not self._page: - return - - async def _runner() -> None: - callback() - - # Flet expects a coroutine function here; schedule it. - self._page.run_task(_runner) diff --git a/src/noteflow/client/components/_thread_mixin.py b/src/noteflow/client/components/_thread_mixin.py deleted file mode 100644 index 20a11e2..0000000 --- a/src/noteflow/client/components/_thread_mixin.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Mixin for background worker thread lifecycle management. - -Provides standardized thread start/stop patterns for UI components -that need background polling or timer threads. -""" - -import threading -from collections.abc import Callable - - -class BackgroundWorkerMixin: - """Mixin providing background worker thread lifecycle management. - - Manages thread creation, start, stop, and cleanup for components - that need background polling loops. - - Usage: - class MyComponent(BackgroundWorkerMixin): - def __init__(self): - self._init_worker() - - def start_polling(self): - self._start_worker(self._poll_loop, "MyPoller") - - def stop_polling(self): - self._stop_worker() - - def _poll_loop(self): - while self._should_run(): - # Do work - self._wait_interval(0.1) - """ - - _worker_thread: threading.Thread | None - _stop_event: threading.Event - - def _init_worker(self) -> None: - """Initialize worker attributes. - - Call this in __init__ of classes using this mixin. - """ - self._worker_thread = None - self._stop_event = threading.Event() - - def _start_worker(self, target: Callable[[], None], name: str) -> None: - """Start background worker thread. - - No-op if worker is already running. - - Args: - target: Callable to run in background thread. - name: Thread name for debugging. - """ - if self._worker_thread and self._worker_thread.is_alive(): - return - - self._stop_event.clear() - self._worker_thread = threading.Thread( - target=target, - daemon=True, - name=name, - ) - self._worker_thread.start() - - def _stop_worker(self, timeout: float = 1.0) -> None: - """Stop background worker thread. - - Signals stop event and waits for thread to finish. - - Args: - timeout: Maximum seconds to wait for thread join. - """ - self._stop_event.set() - if self._worker_thread: - self._worker_thread.join(timeout=timeout) - self._worker_thread = None - - def _should_run(self) -> bool: - """Check if worker loop should continue. - - Returns: - True if worker should continue, False if stop requested. - """ - return not self._stop_event.is_set() - - def _wait_interval(self, seconds: float) -> None: - """Wait for interval, returning early if stop requested. - - Use this instead of time.sleep() in worker loops. - - Args: - seconds: Seconds to wait (returns early if stop signaled). - """ - self._stop_event.wait(seconds) diff --git a/src/noteflow/client/components/annotation_display.py b/src/noteflow/client/components/annotation_display.py deleted file mode 100644 index acef2c5..0000000 --- a/src/noteflow/client/components/annotation_display.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Annotation display component for meeting review. - -Display existing annotations during meeting review with type badges and clickable timestamps. -Reuses patterns from MeetingLibraryComponent (ListView) and SummaryPanelComponent (type badges). -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from typing import TYPE_CHECKING - -import flet as ft - -# REUSE existing formatting utility -from noteflow.infrastructure.export._formatting import format_timestamp - -if TYPE_CHECKING: - from noteflow.client.state import AppState - from noteflow.grpc.client import AnnotationInfo - -logger = logging.getLogger(__name__) - -# Annotation type colors (reused pattern from summary_panel.py) -ANNOTATION_TYPE_COLORS: dict[str, str] = { - "action_item": ft.Colors.GREEN_400, - "decision": ft.Colors.BLUE_400, - "note": ft.Colors.GREY_400, - "risk": ft.Colors.ORANGE_400, -} - -ANNOTATION_TYPE_ICONS: dict[str, str] = { - "action_item": ft.Icons.CHECK_CIRCLE_OUTLINE, - "decision": ft.Icons.GAVEL, - "note": ft.Icons.NOTE, - "risk": ft.Icons.WARNING, -} - -ANNOTATION_TYPE_LABELS: dict[str, str] = { - "action_item": "Action", - "decision": "Decision", - "note": "Note", - "risk": "Risk", -} - - -class AnnotationDisplayComponent: - """Display existing annotations during meeting review. - - Shows annotations sorted by start_time with type badges and clickable timestamps. - Reuses ListView pattern from MeetingLibraryComponent. - """ - - def __init__( - self, - state: AppState, - on_annotation_seek: Callable[[float], None] | None = None, - ) -> None: - """Initialize annotation display. - - Args: - state: Centralized application state. - on_annotation_seek: Callback when annotation is clicked (seek to timestamp). - """ - self._state = state - self._on_annotation_seek = on_annotation_seek - - # UI elements - self._list_view: ft.ListView | None = None - self._header_text: ft.Text | None = None - self._container: ft.Container | None = None - - # State - self._annotations: list[AnnotationInfo] = [] - - def build(self) -> ft.Container: - """Build annotation display UI. - - Returns: - Container with annotation list. - """ - self._header_text = ft.Text( - "Annotations (0)", - size=14, - weight=ft.FontWeight.BOLD, - ) - - self._list_view = ft.ListView( - spacing=5, - padding=10, - height=150, - ) - - self._container = ft.Container( - content=ft.Column( - [ - self._header_text, - ft.Container( - content=self._list_view, - border=ft.border.all(1, ft.Colors.GREY_400), - border_radius=8, - ), - ], - spacing=5, - ), - visible=False, # Hidden until annotations loaded - ) - return self._container - - def load_annotations(self, annotations: list[AnnotationInfo]) -> None: - """Load and display annotations. - - Args: - annotations: List of annotations to display. - """ - # Sort by start_time - self._annotations = sorted(annotations, key=lambda a: a.start_time) - self._state.run_on_ui_thread(self._render_annotations) - - def clear(self) -> None: - """Clear all annotations.""" - self._annotations = [] - if self._list_view: - self._list_view.controls.clear() - if self._header_text: - self._header_text.value = "Annotations (0)" - if self._container: - self._container.visible = False - self._state.request_update() - - def _render_annotations(self) -> None: - """Render annotation list (UI thread only).""" - if not self._list_view or not self._header_text or not self._container: - return - - self._list_view.controls.clear() - - for annotation in self._annotations: - self._list_view.controls.append(self._create_annotation_row(annotation)) - - # Update header and visibility - count = len(self._annotations) - self._header_text.value = f"Annotations ({count})" - self._container.visible = count > 0 - - self._state.request_update() - - def _create_annotation_row(self, annotation: AnnotationInfo) -> ft.Container: - """Create a row for an annotation. - - Args: - annotation: Annotation to display. - - Returns: - Container with annotation details. - """ - # Get type styling - atype = annotation.annotation_type - color = ANNOTATION_TYPE_COLORS.get(atype, ft.Colors.GREY_400) - icon = ANNOTATION_TYPE_ICONS.get(atype, ft.Icons.NOTE) - label = ANNOTATION_TYPE_LABELS.get(atype, atype.title()) - - # Format timestamp - time_str = format_timestamp(annotation.start_time) - - # Type badge - badge = ft.Container( - content=ft.Row( - [ - ft.Icon(icon, size=12, color=color), - ft.Text(label, size=10, color=color, weight=ft.FontWeight.BOLD), - ], - spacing=2, - ), - bgcolor=f"{color}20", # 20% opacity background - padding=ft.padding.symmetric(horizontal=6, vertical=2), - border_radius=4, - ) - - # Annotation text (truncated if long) - text = annotation.text - display_text = f"{text[:80]}..." if len(text) > 80 else text - - row = ft.Row( - [ - badge, - ft.Text(time_str, size=11, color=ft.Colors.GREY_600, width=50), - ft.Text(display_text, size=12, expand=True), - ], - spacing=10, - ) - - return ft.Container( - content=row, - padding=8, - border_radius=4, - on_click=lambda e, a=annotation: self._on_annotation_click(a), - ink=True, - ) - - def _on_annotation_click(self, annotation: AnnotationInfo) -> None: - """Handle annotation row click. - - Args: - annotation: Clicked annotation. - """ - if self._on_annotation_seek: - self._on_annotation_seek(annotation.start_time) - logger.debug( - "Annotation seek: type=%s, time=%.2f", - annotation.annotation_type, - annotation.start_time, - ) diff --git a/src/noteflow/client/components/annotation_toolbar.py b/src/noteflow/client/components/annotation_toolbar.py deleted file mode 100644 index 81f5ffd..0000000 --- a/src/noteflow/client/components/annotation_toolbar.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Annotation toolbar component for adding action items, decisions, and notes. - -Uses AnnotationInfo from grpc.client and NoteFlowClient.add_annotation(). -Does not recreate any types - imports and uses existing ones. -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from typing import TYPE_CHECKING - -import flet as ft - -if TYPE_CHECKING: - from noteflow.client.state import AppState - from noteflow.grpc.client import NoteFlowClient - -logger = logging.getLogger(__name__) - - -class AnnotationToolbarComponent: - """Toolbar for adding annotations during recording or playback. - - Uses NoteFlowClient.add_annotation() to persist annotations. - """ - - def __init__( - self, - state: AppState, - get_client: Callable[[], NoteFlowClient | None], - ) -> None: - """Initialize annotation toolbar. - - Args: - state: Centralized application state. - get_client: Callable that returns current gRPC client or None. - """ - self._state = state - self._get_client = get_client - - # UI elements - self._action_btn: ft.ElevatedButton | None = None - self._decision_btn: ft.ElevatedButton | None = None - self._note_btn: ft.ElevatedButton | None = None - self._risk_btn: ft.ElevatedButton | None = None - self._row: ft.Row | None = None - - # Dialog elements - self._dialog: ft.AlertDialog | None = None - self._text_field: ft.TextField | None = None - self._current_annotation_type: str = "" - - def build(self) -> ft.Row: - """Build annotation toolbar UI. - - Returns: - Row containing annotation buttons. - """ - self._action_btn = ft.ElevatedButton( - "Action Item", - icon=ft.Icons.CHECK_CIRCLE_OUTLINE, - on_click=lambda e: self._show_annotation_dialog("action_item"), - disabled=True, - ) - self._decision_btn = ft.ElevatedButton( - "Decision", - icon=ft.Icons.GAVEL, - on_click=lambda e: self._show_annotation_dialog("decision"), - disabled=True, - ) - self._note_btn = ft.ElevatedButton( - "Note", - icon=ft.Icons.NOTE_ADD, - on_click=lambda e: self._show_annotation_dialog("note"), - disabled=True, - ) - self._risk_btn = ft.ElevatedButton( - "Risk", - icon=ft.Icons.WARNING_AMBER, - on_click=lambda e: self._show_annotation_dialog("risk"), - disabled=True, - ) - - self._row = ft.Row( - [self._action_btn, self._decision_btn, self._note_btn, self._risk_btn], - visible=False, - ) - return self._row - - def set_enabled(self, enabled: bool) -> None: - """Enable or disable annotation buttons. - - Args: - enabled: Whether buttons should be enabled. - """ - if self._action_btn: - self._action_btn.disabled = not enabled - if self._decision_btn: - self._decision_btn.disabled = not enabled - if self._note_btn: - self._note_btn.disabled = not enabled - if self._risk_btn: - self._risk_btn.disabled = not enabled - self._state.request_update() - - def set_visible(self, visible: bool) -> None: - """Set visibility of annotation toolbar. - - Args: - visible: Whether toolbar should be visible. - """ - if self._row: - self._row.visible = visible - self._state.request_update() - - def _show_annotation_dialog(self, annotation_type: str) -> None: - """Show dialog for entering annotation text. - - Args: - annotation_type: Type of annotation (action_item, decision, note). - """ - self._current_annotation_type = annotation_type - - # Format type for display - type_display = annotation_type.replace("_", " ").title() - - self._text_field = ft.TextField( - label=f"{type_display} Text", - multiline=True, - min_lines=2, - max_lines=4, - width=400, - autofocus=True, - ) - - self._dialog = ft.AlertDialog( - title=ft.Text(f"Add {type_display}"), - content=self._text_field, - actions=[ - ft.TextButton("Cancel", on_click=self._close_dialog), - ft.ElevatedButton("Add", on_click=self._submit_annotation), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - # Show dialog - if self._state._page: - self._state._page.dialog = self._dialog - self._dialog.open = True - self._state.request_update() - - def _close_dialog(self, e: ft.ControlEvent | None = None) -> None: - """Close the annotation dialog.""" - if self._dialog: - self._dialog.open = False - self._state.request_update() - - def _submit_annotation(self, e: ft.ControlEvent) -> None: - """Submit the annotation to the server.""" - if not self._text_field: - return - - text = self._text_field.value or "" - if not text.strip(): - return - - self._close_dialog() - - # Get current timestamp - timestamp = self._get_current_timestamp() - - # Submit to server - client = self._get_client() - if not client: - logger.warning("No gRPC client available for annotation") - return - - meeting = self._state.current_meeting - if not meeting: - logger.warning("No current meeting for annotation") - return - - try: - if annotation := client.add_annotation( - meeting_id=meeting.id, - annotation_type=self._current_annotation_type, - text=text.strip(), - start_time=timestamp, - end_time=timestamp, # Point annotation - ): - self._state.annotations.append(annotation) - logger.info( - "Added annotation: %s at %.2f", self._current_annotation_type, timestamp - ) - else: - logger.error("Failed to add annotation") - except Exception as exc: - logger.error("Error adding annotation: %s", exc) - - def _get_current_timestamp(self) -> float: - """Get current timestamp for annotation. - - Returns timestamp from playback position (during playback) or - recording elapsed time (during recording). - - Returns: - Current timestamp in seconds. - """ - # During playback, use playback position - if self._state.playback_position > 0: - return self._state.playback_position - - # During recording, use elapsed seconds - return float(self._state.elapsed_seconds) diff --git a/src/noteflow/client/components/connection_panel.py b/src/noteflow/client/components/connection_panel.py deleted file mode 100644 index 0131b4c..0000000 --- a/src/noteflow/client/components/connection_panel.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Server connection management panel. - -Uses NoteFlowClient directly (not wrapped) and follows same callback pattern. -Does not recreate any types - imports and uses existing ones. -""" - -from __future__ import annotations - -import logging -import threading -from collections.abc import Callable -from typing import TYPE_CHECKING, Final - -import flet as ft - -# REUSE existing types - do not recreate -from noteflow.grpc.client import NoteFlowClient, ServerInfo - -if TYPE_CHECKING: - from noteflow.client.state import AppState - -logger = logging.getLogger(__name__) - -RECONNECT_ATTEMPTS: Final[int] = 3 -RECONNECT_DELAY_SECONDS: Final[float] = 2.0 - - -class ConnectionPanelComponent: - """Server connection management panel. - - Uses NoteFlowClient directly (not wrapped) and follows same callback pattern. - """ - - def __init__( - self, - state: AppState, - on_connected: Callable[[NoteFlowClient, ServerInfo], None] | None = None, - on_disconnected: Callable[[], None] | None = None, - on_transcript_callback: Callable[..., None] | None = None, - on_connection_change_callback: Callable[[bool, str], None] | None = None, - ) -> None: - """Initialize connection panel. - - Args: - state: Centralized application state. - on_connected: Callback when connected with client and server info. - on_disconnected: Callback when disconnected. - on_transcript_callback: Callback to pass to NoteFlowClient for transcripts. - on_connection_change_callback: Callback to pass to NoteFlowClient for connection changes. - """ - self._state = state - self._on_connected = on_connected - self._on_disconnected = on_disconnected - self._on_transcript_callback = on_transcript_callback - self._on_connection_change_callback = on_connection_change_callback - self._client: NoteFlowClient | None = None - self._manual_disconnect = False - self._auto_reconnect_enabled = False - self._reconnect_thread: threading.Thread | None = None - self._reconnect_stop_event = threading.Event() - self._reconnect_lock = threading.Lock() - self._reconnect_in_progress = False - self._suppress_connection_events = False - - self._server_field: ft.TextField | None = None - self._connect_btn: ft.ElevatedButton | None = None - self._status_text: ft.Text | None = None - self._server_info_text: ft.Text | None = None - - @property - def client(self) -> NoteFlowClient | None: - """Get current gRPC client instance.""" - return self._client - - def build(self) -> ft.Column: - """Build connection panel UI. - - Returns: - Column containing connection controls and status. - """ - self._status_text = ft.Text( - "Not connected", - size=14, - color=ft.Colors.GREY_600, - ) - self._server_info_text = ft.Text( - "", - size=12, - color=ft.Colors.GREY_500, - ) - - self._server_field = ft.TextField( - value=self._state.server_address, - label="Server Address", - width=300, - on_change=self._on_server_change, - ) - self._connect_btn = ft.ElevatedButton( - "Connect", - on_click=self._on_connect_click, - icon=ft.Icons.CLOUD_OFF, - ) - - return ft.Column( - [ - self._status_text, - self._server_info_text, - ft.Row([self._server_field, self._connect_btn]), - ], - spacing=10, - ) - - def update_button_state(self) -> None: - """Update connect button state based on connection status.""" - if self._connect_btn: - if self._state.connected: - self._connect_btn.text = "Disconnect" - self._connect_btn.icon = ft.Icons.CLOUD_DONE - else: - self._connect_btn.text = "Connect" - self._connect_btn.icon = ft.Icons.CLOUD_OFF - self._state.request_update() - - def disconnect(self) -> None: - """Disconnect from server.""" - self._manual_disconnect = True - self._auto_reconnect_enabled = False - self._cancel_reconnect() - if self._client: - self._suppress_connection_events = True - try: - self._client.disconnect() - finally: - self._suppress_connection_events = False - self._client = None - - self._state.connected = False - self._state.server_info = None - - self._update_status("Disconnected", ft.Colors.GREY_600) - self.update_button_state() - - # Follow NoteFlowClient callback pattern with error handling - if self._on_disconnected: - try: - self._on_disconnected() - except Exception as e: - logger.error("on_disconnected callback error: %s", e) - - def _on_server_change(self, e: ft.ControlEvent) -> None: - """Handle server address change. - - Args: - e: Control event. - """ - self._state.server_address = str(e.control.value) - - def _on_connect_click(self, e: ft.ControlEvent) -> None: - """Handle connect/disconnect button click. - - Args: - e: Control event. - """ - if self._state.connected: - self.disconnect() - else: - self._manual_disconnect = False - self._cancel_reconnect() - threading.Thread(target=self._connect, daemon=True).start() - - def _connect(self) -> None: - """Connect to server (background thread).""" - self._update_status("Connecting...", ft.Colors.ORANGE) - - try: - if self._client: - self._suppress_connection_events = True - try: - self._client.disconnect() - finally: - self._suppress_connection_events = False - - # Create client with callbacks - use NoteFlowClient directly - self._client = NoteFlowClient( - server_address=self._state.server_address, - on_transcript=self._on_transcript_callback, - on_connection_change=self._handle_connection_change, - ) - - if self._client.connect(timeout=10.0): - if info := self._client.get_server_info(): - self._state.connected = True - self._state.server_info = info - self._state.run_on_ui_thread(lambda: self._on_connect_success(info)) - else: - self._update_status("Failed to get server info", ft.Colors.RED) - if self._client: - self._suppress_connection_events = True - try: - self._client.disconnect() - finally: - self._suppress_connection_events = False - self._client = None - self._state.connected = False - self._state.run_on_ui_thread(self.update_button_state) - else: - self._update_status("Connection failed", ft.Colors.RED) - except Exception as exc: - logger.error("Connection error: %s", exc) - self._update_status(f"Error: {exc}", ft.Colors.RED) - - def _handle_connection_change(self, connected: bool, message: str) -> None: - """Handle connection state change from NoteFlowClient. - - Args: - connected: Connection state. - message: Status message. - """ - if self._suppress_connection_events: - return - - self._state.connected = connected - - if connected: - self._auto_reconnect_enabled = True - self._manual_disconnect = False - self._reconnect_stop_event.set() - self._reconnect_in_progress = False - self._state.run_on_ui_thread( - lambda: self._update_status(f"Connected: {message}", ft.Colors.GREEN) - ) - elif self._manual_disconnect or not self._auto_reconnect_enabled: - self._state.run_on_ui_thread( - lambda: self._update_status(f"Disconnected: {message}", ft.Colors.RED) - ) - elif not self._reconnect_in_progress: - self._start_reconnect_loop(message) - - self._state.run_on_ui_thread(self.update_button_state) - - # Forward to external callback if provided - if (callback := self._on_connection_change_callback) is not None: - try: - self._state.run_on_ui_thread(lambda: callback(connected, message)) - except Exception as e: - logger.error("on_connection_change callback error: %s", e) - - def _on_connect_success(self, info: ServerInfo) -> None: - """Handle successful connection (UI thread). - - Args: - info: Server info from connection. - """ - self._auto_reconnect_enabled = True - self._reconnect_stop_event.set() - self._reconnect_in_progress = False - self.update_button_state() - self._update_status("Connected", ft.Colors.GREEN) - - # Update server info display - if self._server_info_text: - asr_status = "ready" if info.asr_ready else "not ready" - self._server_info_text.value = ( - f"Server v{info.version} | " - f"ASR: {info.asr_model} ({asr_status}) | " - f"Active meetings: {info.active_meetings}" - ) - - self._state.request_update() - - # Follow NoteFlowClient callback pattern with error handling - if self._on_connected and self._client: - try: - self._on_connected(self._client, info) - except Exception as e: - logger.error("on_connected callback error: %s", e) - - def _start_reconnect_loop(self, message: str) -> None: - """Start background reconnect attempts.""" - with self._reconnect_lock: - if self._reconnect_in_progress: - return - - self._reconnect_in_progress = True - self._reconnect_stop_event.clear() - self._reconnect_thread = threading.Thread( - target=self._reconnect_worker, - args=(message,), - daemon=True, - ) - self._reconnect_thread.start() - - def _reconnect_worker(self, message: str) -> None: - """Attempt to reconnect several times before giving up.""" - if not self._client: - self._reconnect_in_progress = False - return - - # Stop streaming here to avoid audio queue growth while reconnecting. - self._client.stop_streaming() - - for attempt in range(1, RECONNECT_ATTEMPTS + 1): - if self._reconnect_stop_event.is_set(): - self._reconnect_in_progress = False - return - - warning = f"Disconnected: {message}. Reconnecting ({attempt}/{RECONNECT_ATTEMPTS})" - if self._state.recording: - warning += " - recording will stop if not reconnected." - self._update_status(warning, ft.Colors.ORANGE) - - if self._attempt_reconnect(): - self._reconnect_in_progress = False - return - - self._reconnect_stop_event.wait(RECONNECT_DELAY_SECONDS) - - self._reconnect_in_progress = False - self._auto_reconnect_enabled = False - if self._state.recording: - final_message = "Reconnection failed. Recording stopped." - else: - final_message = "Reconnection failed." - self._finalize_disconnect(final_message) - - def _attempt_reconnect(self) -> bool: - """Attempt a single reconnect. - - Returns: - True if reconnected successfully. - """ - if not self._client: - return False - - self._suppress_connection_events = True - try: - self._client.disconnect() - finally: - self._suppress_connection_events = False - - if not self._client.connect(timeout=10.0): - return False - - info = self._client.get_server_info() - if not info: - self._suppress_connection_events = True - try: - self._client.disconnect() - finally: - self._suppress_connection_events = False - return False - - self._state.connected = True - self._state.server_info = info - self._state.run_on_ui_thread(lambda: self._on_connect_success(info)) - return True - - def _finalize_disconnect(self, message: str) -> None: - """Finalize disconnect after failed reconnect attempts.""" - self._state.connected = False - self._state.server_info = None - self._update_status(message, ft.Colors.RED) - self._state.run_on_ui_thread(self.update_button_state) - - def handle_disconnect() -> None: - if self._on_disconnected: - try: - self._on_disconnected() - except Exception as e: - logger.error("on_disconnected callback error: %s", e) - - if self._client: - threading.Thread(target=self._disconnect_client, daemon=True).start() - - self._state.run_on_ui_thread(handle_disconnect) - - def _disconnect_client(self) -> None: - """Disconnect client without triggering connection callbacks.""" - if not self._client: - return - - self._suppress_connection_events = True - try: - self._client.disconnect() - finally: - self._suppress_connection_events = False - self._client = None - - def _cancel_reconnect(self) -> None: - """Stop any in-progress reconnect attempt.""" - self._reconnect_stop_event.set() - - def _update_status(self, message: str, color: str) -> None: - """Update status text. - - Args: - message: Status message. - color: Text color. - """ - - def update() -> None: - if self._status_text: - self._status_text.value = message - self._status_text.color = color - self._state.request_update() - - self._state.run_on_ui_thread(update) diff --git a/src/noteflow/client/components/meeting_library.py b/src/noteflow/client/components/meeting_library.py deleted file mode 100644 index 5fce8b1..0000000 --- a/src/noteflow/client/components/meeting_library.py +++ /dev/null @@ -1,777 +0,0 @@ -"""Meeting library component for browsing and exporting meetings. - -Uses MeetingInfo, ExportResult from grpc.client and format_datetime from _formatting. -Does not recreate any types - imports and uses existing ones. -""" - -from __future__ import annotations - -import logging -import threading -import time -from collections.abc import Callable -from datetime import datetime -from typing import TYPE_CHECKING - -import flet as ft - -# REUSE existing formatting - do not recreate -from noteflow.infrastructure.export._formatting import format_datetime - -if TYPE_CHECKING: - from noteflow.client.state import AppState - from noteflow.grpc.client import MeetingInfo, NoteFlowClient - -logger = logging.getLogger(__name__) - - -class MeetingLibraryComponent: - """Meeting library for browsing and exporting meetings. - - Uses NoteFlowClient.list_meetings() and export_transcript() for data. - """ - - DIARIZATION_POLL_INTERVAL_SECONDS: float = 2.0 - - def __init__( - self, - state: AppState, - get_client: Callable[[], NoteFlowClient | None], - on_meeting_selected: Callable[[MeetingInfo], None] | None = None, - ) -> None: - """Initialize meeting library. - - Args: - state: Centralized application state. - get_client: Callable that returns current gRPC client or None. - on_meeting_selected: Callback when a meeting is selected. - """ - self._state = state - self._get_client = get_client - self._on_meeting_selected = on_meeting_selected - - # UI elements - self._search_field: ft.TextField | None = None - self._list_view: ft.ListView | None = None - self._export_btn: ft.ElevatedButton | None = None - self._analyze_btn: ft.ElevatedButton | None = None - self._rename_btn: ft.ElevatedButton | None = None - self._refresh_btn: ft.IconButton | None = None - self._column: ft.Column | None = None - - # Export dialog - self._export_dialog: ft.AlertDialog | None = None - self._format_dropdown: ft.Dropdown | None = None - - # Analyze speakers dialog - self._analyze_dialog: ft.AlertDialog | None = None - self._num_speakers_field: ft.TextField | None = None - - # Rename speakers dialog - self._rename_dialog: ft.AlertDialog | None = None - self._rename_fields: dict[str, ft.TextField] = {} - - def build(self) -> ft.Column: - """Build meeting library UI. - - Returns: - Column containing search, list, and export controls. - """ - self._search_field = ft.TextField( - label="Search meetings", - prefix_icon=ft.Icons.SEARCH, - on_change=self._on_search_change, - expand=True, - ) - self._refresh_btn = ft.IconButton( - icon=ft.Icons.REFRESH, - tooltip="Refresh meetings", - on_click=self._on_refresh_click, - ) - self._export_btn = ft.ElevatedButton( - "Export", - icon=ft.Icons.DOWNLOAD, - on_click=self._show_export_dialog, - disabled=True, - ) - self._analyze_btn = ft.ElevatedButton( - "Refine Speakers", - icon=ft.Icons.RECORD_VOICE_OVER, - on_click=self._show_analyze_dialog, - disabled=True, - ) - self._rename_btn = ft.ElevatedButton( - "Rename Speakers", - icon=ft.Icons.EDIT, - on_click=self._show_rename_dialog, - disabled=True, - ) - - self._list_view = ft.ListView( - spacing=5, - padding=10, - height=200, - ) - - self._column = ft.Column( - [ - ft.Row([self._search_field, self._refresh_btn]), - ft.Container( - content=self._list_view, - border=ft.border.all(1, ft.Colors.GREY_400), - border_radius=8, - ), - ft.Row( - [self._analyze_btn, self._rename_btn, self._export_btn], - alignment=ft.MainAxisAlignment.END, - spacing=10, - ), - ], - spacing=10, - ) - return self._column - - def refresh_meetings(self) -> None: - """Refresh meeting list from server.""" - client = self._get_client() - if not client: - logger.warning("No gRPC client available") - return - - try: - meetings = client.list_meetings(limit=50) - self._state.meetings = meetings - self._state.run_on_ui_thread(self._render_meetings) - except Exception as exc: - logger.error("Error fetching meetings: %s", exc) - - def _on_search_change(self, e: ft.ControlEvent) -> None: - """Handle search field change.""" - self._render_meetings() - - def _on_refresh_click(self, e: ft.ControlEvent) -> None: - """Handle refresh button click.""" - self.refresh_meetings() - - def _render_meetings(self) -> None: - """Render meeting list (UI thread only).""" - if not self._list_view: - return - - self._list_view.controls.clear() - - # Filter by search query - search_query = (self._search_field.value or "").lower() if self._search_field else "" - filtered_meetings = [m for m in self._state.meetings if search_query in m.title.lower()] - - for meeting in filtered_meetings: - self._list_view.controls.append(self._create_meeting_row(meeting)) - - self._state.request_update() - - def _create_meeting_row(self, meeting: MeetingInfo) -> ft.Container: - """Create a row for a meeting. - - Args: - meeting: Meeting info to display. - - Returns: - Container with meeting details. - """ - # Format datetime from timestamp - created_dt = datetime.fromtimestamp(meeting.created_at) if meeting.created_at else None - date_str = format_datetime(created_dt) - - # Format duration - duration = meeting.duration_seconds - duration_str = f"{int(duration // 60)}:{int(duration % 60):02d}" if duration else "--:--" - - is_selected = self._state.selected_meeting and self._state.selected_meeting.id == meeting.id - - row = ft.Row( - [ - ft.Column( - [ - ft.Text(meeting.title, weight=ft.FontWeight.BOLD, size=14), - ft.Text( - f"{date_str} | {meeting.state} | {meeting.segment_count} segments | {duration_str}", - size=11, - color=ft.Colors.GREY_600, - ), - ], - spacing=2, - expand=True, - ), - ] - ) - - return ft.Container( - content=row, - padding=10, - border_radius=4, - bgcolor=ft.Colors.BLUE_50 if is_selected else None, - on_click=lambda e, m=meeting: self._on_meeting_click(m), - ink=True, - ) - - def _on_meeting_click(self, meeting: MeetingInfo) -> None: - """Handle meeting row click. - - Args: - meeting: Selected meeting. - """ - self._state.selected_meeting = meeting - - # Enable action buttons - if self._export_btn: - self._export_btn.disabled = False - if self._analyze_btn: - self._analyze_btn.disabled = not self._can_refine_speakers(meeting) - if self._rename_btn: - self._rename_btn.disabled = not self._can_refine_speakers(meeting) - - # Re-render to update selection - self._render_meetings() - - # Notify callback - if self._on_meeting_selected: - self._on_meeting_selected(meeting) - - def _show_export_dialog(self, e: ft.ControlEvent) -> None: - """Show export format selection dialog.""" - if not self._state.selected_meeting: - return - - self._format_dropdown = ft.Dropdown( - label="Export Format", - options=[ - ft.dropdown.Option("markdown", "Markdown (.md)"), - ft.dropdown.Option("html", "HTML (.html)"), - ], - value="markdown", - width=200, - ) - - self._export_dialog = ft.AlertDialog( - title=ft.Text("Export Transcript"), - content=ft.Column( - [ - ft.Text(f"Meeting: {self._state.selected_meeting.title}"), - self._format_dropdown, - ], - spacing=10, - tight=True, - ), - actions=[ - ft.TextButton("Cancel", on_click=self._close_export_dialog), - ft.ElevatedButton("Export", on_click=self._do_export), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - if self._state._page: - self._state._page.dialog = self._export_dialog - self._export_dialog.open = True - self._state.request_update() - - def _close_export_dialog(self, e: ft.ControlEvent | None = None) -> None: - """Close the export dialog.""" - if self._export_dialog: - self._export_dialog.open = False - self._state.request_update() - - def _do_export(self, e: ft.ControlEvent) -> None: - """Perform the export.""" - if not self._state.selected_meeting or not self._format_dropdown: - return - - format_name = self._format_dropdown.value or "markdown" - meeting_id = self._state.selected_meeting.id - - self._close_export_dialog() - - client = self._get_client() - if not client: - logger.warning("No gRPC client available for export") - return - - try: - if result := client.export_transcript(meeting_id, format_name): - self._save_export(result.content, result.file_extension) - else: - logger.error("Export failed - no result returned") - except Exception as exc: - logger.error("Error exporting transcript: %s", exc) - - def _save_export(self, content: str, extension: str) -> None: - """Save exported content to file. - - Args: - content: Export content. - extension: File extension. - """ - if not self._state.selected_meeting: - return - - # Create filename from meeting title - safe_title = "".join( - c if c.isalnum() or c in " -_" else "_" for c in self._state.selected_meeting.title - ) - filename = f"{safe_title}.{extension}" - - # Use FilePicker for save dialog - if self._state._page: - - def on_save(e: ft.FilePickerResultEvent) -> None: - if e.path: - try: - with open(e.path, "w", encoding="utf-8") as f: - f.write(content) - logger.info("Exported to: %s", e.path) - except OSError as exc: - logger.error("Error saving export: %s", exc) - - picker = ft.FilePicker(on_result=on_save) - self._state._page.overlay.append(picker) - self._state._page.update() - picker.save_file( - file_name=filename, - allowed_extensions=[extension], - ) - - # ========================================================================= - # Speaker Refinement Methods - # ========================================================================= - - def _show_analyze_dialog(self, e: ft.ControlEvent) -> None: - """Show speaker refinement dialog.""" - if not self._state.selected_meeting: - return - - if not self._can_refine_speakers(self._state.selected_meeting): - self._show_simple_dialog( - "Meeting still active", - ft.Text("Stop the meeting before refining speakers."), - ) - return - - self._num_speakers_field = ft.TextField( - label="Number of speakers (optional)", - hint_text="Leave empty for auto-detect", - width=200, - keyboard_type=ft.KeyboardType.NUMBER, - ) - - self._analyze_dialog = ft.AlertDialog( - title=ft.Text("Refine Speakers"), - content=ft.Column( - [ - ft.Text(f"Meeting: {self._state.selected_meeting.title}"), - ft.Text( - "Refine speaker labels using offline diarization.", - size=12, - color=ft.Colors.GREY_600, - ), - self._num_speakers_field, - ], - spacing=10, - tight=True, - ), - actions=[ - ft.TextButton("Cancel", on_click=self._close_analyze_dialog), - ft.ElevatedButton("Analyze", on_click=self._do_analyze), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - if self._state._page: - self._state._page.dialog = self._analyze_dialog - self._analyze_dialog.open = True - self._state.request_update() - - def _close_analyze_dialog(self, e: ft.ControlEvent | None = None) -> None: - """Close the analyze dialog.""" - if self._analyze_dialog: - self._analyze_dialog.open = False - self._state.request_update() - - def _do_analyze(self, e: ft.ControlEvent) -> None: - """Perform speaker analysis.""" - if not self._state.selected_meeting: - return - - # Parse number of speakers (optional) - num_speakers: int | None = None - if self._num_speakers_field and self._num_speakers_field.value: - try: - num_speakers = int(self._num_speakers_field.value) - if num_speakers < 1: - num_speakers = None - except ValueError: - logger.debug( - "Invalid speaker count input '%s', using auto-detection", - self._num_speakers_field.value, - ) - - meeting_id = self._state.selected_meeting.id - self._close_analyze_dialog() - - client = self._get_client() - if not client: - logger.warning("No gRPC client available for analysis") - return - - # Show progress indicator - self._show_analysis_progress("Starting...") - - try: - result = client.refine_speaker_diarization(meeting_id, num_speakers) - except Exception as exc: - logger.error("Error analyzing speakers: %s", exc) - self._show_analysis_error(str(exc)) - return - - if not result: - self._show_analysis_error("Analysis failed - no response from server") - return - - if result.is_terminal: - if result.success: - self._show_analysis_result(result.segments_updated, result.speaker_ids) - else: - self._show_analysis_error(result.error_message or "Analysis failed") - return - - if not result.job_id: - self._show_analysis_error(result.error_message or "Server did not return job ID") - return - - # Job queued/running - poll for completion - self._show_analysis_progress(self._format_job_status(result.status)) - self._start_diarization_poll(result.job_id) - - def _show_analysis_progress(self, status: str = "Refining...") -> None: - """Show refinement in progress indicator.""" - if self._analyze_btn: - self._analyze_btn.disabled = True - self._analyze_btn.text = status - self._state.request_update() - - def _show_analysis_result(self, segments_updated: int, speaker_ids: list[str]) -> None: - """Show refinement success result. - - Args: - segments_updated: Number of segments with speaker labels. - speaker_ids: List of detected speaker IDs. - """ - if self._analyze_btn: - self._analyze_btn.disabled = False - self._analyze_btn.text = "Refine Speakers" - - speaker_list = ", ".join(speaker_ids) if speaker_ids else "None found" - - result_dialog = ft.AlertDialog( - title=ft.Text("Refinement Complete"), - content=ft.Column( - [ - ft.Text(f"Segments updated: {segments_updated}"), - ft.Text(f"Speakers found: {speaker_list}"), - ft.Text( - "Reload the meeting to see speaker labels.", - size=12, - color=ft.Colors.GREY_600, - italic=True, - ), - ], - spacing=5, - tight=True, - ), - actions=[ft.TextButton("OK", on_click=lambda e: self._close_result_dialog(e))], - ) - - if self._state._page: - self._state._page.dialog = result_dialog - result_dialog.open = True - self._state.request_update() - - def _show_analysis_error(self, error_message: str) -> None: - """Show analysis error. - - Args: - error_message: Error description. - """ - if self._analyze_btn: - self._analyze_btn.disabled = False - self._analyze_btn.text = "Refine Speakers" - self._show_simple_dialog("Refinement Failed", ft.Text(error_message)) - - def _close_result_dialog(self, e: ft.ControlEvent) -> None: - """Close any result dialog.""" - if self._state._page and self._state._page.dialog: - self._state._page.dialog.open = False - self._state.request_update() - - def _start_diarization_poll(self, job_id: str) -> None: - """Start polling for diarization job completion.""" - page = self._state._page - if page and hasattr(page, "run_thread"): - page.run_thread(lambda: self._poll_diarization_job(job_id)) - return - - threading.Thread( - target=self._poll_diarization_job, - args=(job_id,), - daemon=True, - name="diarization-poll", - ).start() - - def _poll_diarization_job(self, job_id: str) -> None: - """Poll background diarization job until completion.""" - client = self._get_client() - if not client: - self._state.run_on_ui_thread( - lambda: self._show_analysis_error("No gRPC client available for polling") - ) - return - - while True: - result = client.get_diarization_job_status(job_id) - if not result: - self._state.run_on_ui_thread( - lambda: self._show_analysis_error("Failed to fetch diarization status") - ) - return - - if result.is_terminal: - if result.success: - self._state.run_on_ui_thread( - lambda r=result: self._show_analysis_result( - r.segments_updated, - r.speaker_ids, - ) - ) - else: - self._state.run_on_ui_thread( - lambda r=result: self._show_analysis_error( - r.error_message or "Diarization failed" - ) - ) - return - - # Update status text while running - self._state.run_on_ui_thread( - lambda r=result: self._show_analysis_progress(self._format_job_status(r.status)) - ) - time.sleep(self.DIARIZATION_POLL_INTERVAL_SECONDS) - - @staticmethod - def _format_job_status(status: str) -> str: - """Format job status for button label.""" - return { - "queued": "Queued...", - "running": "Refining...", - }.get(status, "Refining...") - - def _show_simple_dialog(self, title: str, content: ft.Control) -> None: - """Show a simple dialog with title, content, and OK button. - - Args: - title: Dialog title. - content: Dialog content control. - """ - dialog = ft.AlertDialog( - title=ft.Text(title), - content=content, - actions=[ft.TextButton("OK", on_click=self._close_result_dialog)], - ) - if self._state._page: - self._state._page.dialog = dialog - dialog.open = True - self._state.request_update() - - # ========================================================================= - # Speaker Rename Methods - # ========================================================================= - - def _show_rename_dialog(self, e: ft.ControlEvent) -> None: - """Show speaker rename dialog with current speaker IDs.""" - if not self._state.selected_meeting: - return - - if not self._can_refine_speakers(self._state.selected_meeting): - self._show_simple_dialog( - "Meeting still active", - ft.Text("Stop the meeting before renaming speakers."), - ) - return - - client = self._get_client() - if not client: - logger.warning("No gRPC client available") - return - - # Get segments to extract distinct speaker IDs - meeting_id = self._state.selected_meeting.id - segments = client.get_meeting_segments(meeting_id) - - # Extract distinct speaker IDs - speaker_ids = sorted({s.speaker_id for s in segments if s.speaker_id}) - - if not speaker_ids: - self._show_no_speakers_message() - return - - # Create text fields for each speaker - self._rename_fields.clear() - speaker_controls: list[ft.Control] = [] - - for speaker_id in speaker_ids: - field = ft.TextField( - label=f"{speaker_id}", - hint_text="Enter new name", - width=200, - ) - self._rename_fields[speaker_id] = field - speaker_controls.append( - ft.Row( - [ - ft.Text(speaker_id, width=120, size=12), - ft.Icon(ft.Icons.ARROW_RIGHT, size=16), - field, - ], - alignment=ft.MainAxisAlignment.START, - ) - ) - - self._rename_dialog = ft.AlertDialog( - title=ft.Text("Rename Speakers"), - content=ft.Column( - [ - ft.Text(f"Meeting: {self._state.selected_meeting.title}"), - ft.Text( - "Enter new names for speakers (leave blank to keep current):", - size=12, - color=ft.Colors.GREY_600, - ), - ft.Divider(), - *speaker_controls, - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - height=300, - ), - actions=[ - ft.TextButton("Cancel", on_click=self._close_rename_dialog), - ft.ElevatedButton("Apply", on_click=self._do_rename), - ], - actions_alignment=ft.MainAxisAlignment.END, - ) - - if self._state._page: - self._state._page.dialog = self._rename_dialog - self._rename_dialog.open = True - self._state.request_update() - - def _close_rename_dialog(self, e: ft.ControlEvent | None = None) -> None: - """Close the rename dialog.""" - if self._rename_dialog: - self._rename_dialog.open = False - self._state.request_update() - - def _show_no_speakers_message(self) -> None: - """Show message when no speakers found.""" - self._show_simple_dialog( - "No Speakers Found", - ft.Text( - "This meeting has no speaker labels. " - "Run 'Refine Speakers' first to identify speakers." - ), - ) - - def _do_rename(self, e: ft.ControlEvent) -> None: - """Apply speaker renames.""" - if not self._state.selected_meeting: - return - - client = self._get_client() - if not client: - logger.warning("No gRPC client available") - return - - meeting_id = self._state.selected_meeting.id - self._close_rename_dialog() - - # Collect renames (only non-empty values) - renames: list[tuple[str, str]] = [] - for old_id, field in self._rename_fields.items(): - new_name = (field.value or "").strip() - if new_name and new_name != old_id: - renames.append((old_id, new_name)) - - if not renames: - return - - # Apply renames - total_updated = 0 - errors: list[str] = [] - - for old_id, new_name in renames: - try: - result = client.rename_speaker(meeting_id, old_id, new_name) - if result and result.success: - total_updated += result.segments_updated - else: - errors.append(f"{old_id}: rename failed") - except Exception as exc: - logger.error("Error renaming speaker %s: %s", old_id, exc) - errors.append(f"{old_id}: {exc}") - - # Show result - if errors: - self._show_rename_errors(errors) - else: - self._show_rename_success(total_updated, len(renames)) - - def _show_rename_success(self, segments_updated: int, speakers_renamed: int) -> None: - """Show rename success message. - - Args: - segments_updated: Total number of segments updated. - speakers_renamed: Number of speakers renamed. - """ - success_dialog = ft.AlertDialog( - title=ft.Text("Rename Complete"), - content=ft.Column( - [ - ft.Text(f"Renamed {speakers_renamed} speaker(s)"), - ft.Text(f"Updated {segments_updated} segment(s)"), - ft.Text( - "Reload the meeting to see the new speaker names.", - size=12, - color=ft.Colors.GREY_600, - italic=True, - ), - ], - spacing=5, - tight=True, - ), - actions=[ft.TextButton("OK", on_click=lambda e: self._close_result_dialog(e))], - ) - - if self._state._page: - self._state._page.dialog = success_dialog - success_dialog.open = True - self._state.request_update() - - def _show_rename_errors(self, errors: list[str]) -> None: - """Show rename errors. - - Args: - errors: List of error messages. - """ - self._show_simple_dialog("Rename Errors", ft.Text("\n".join(errors))) - - @staticmethod - def _can_refine_speakers(meeting: MeetingInfo) -> bool: - """Return True when meeting is stopped/completed and safe to refine/rename.""" - return meeting.state in {"stopped", "completed", "error"} diff --git a/src/noteflow/client/components/playback_controls.py b/src/noteflow/client/components/playback_controls.py deleted file mode 100644 index f1de798..0000000 --- a/src/noteflow/client/components/playback_controls.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Playback controls component with play/pause/stop and timeline. - -Uses SoundDevicePlayback from infrastructure.audio and format_timestamp from _formatting. -Receives position updates via callback from SoundDevicePlayback. -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from typing import TYPE_CHECKING - -import flet as ft - -# REUSE existing types - do not recreate -from noteflow.infrastructure.audio import PlaybackState -from noteflow.infrastructure.export._formatting import format_timestamp - -if TYPE_CHECKING: - from noteflow.client.state import AppState - -logger = logging.getLogger(__name__) - - -class PlaybackControlsComponent: - """Audio playback controls with play/pause/stop and timeline. - - Uses SoundDevicePlayback from state and format_timestamp from _formatting. - Receives position updates via callback from SoundDevicePlayback. - """ - - def __init__( - self, - state: AppState, - on_position_change: Callable[[float], None] | None = None, - ) -> None: - """Initialize playback controls component. - - Args: - state: Centralized application state. - on_position_change: Callback when playback position changes. - """ - self._state = state - self._on_position_change = on_position_change - self._active = False - - # UI elements - self._play_btn: ft.IconButton | None = None - self._stop_btn: ft.IconButton | None = None - self._position_label: ft.Text | None = None - self._duration_label: ft.Text | None = None - self._timeline_slider: ft.Slider | None = None - self._row: ft.Row | None = None - - def build(self) -> ft.Row: - """Build playback controls UI. - - Returns: - Row containing playback buttons and timeline. - """ - self._play_btn = ft.IconButton( - icon=ft.Icons.PLAY_ARROW, - icon_color=ft.Colors.GREEN, - tooltip="Play", - on_click=self._on_play_click, - disabled=True, - ) - self._stop_btn = ft.IconButton( - icon=ft.Icons.STOP, - icon_color=ft.Colors.RED, - tooltip="Stop", - on_click=self._on_stop_click, - disabled=True, - ) - self._position_label = ft.Text("00:00", size=12, width=50) - self._duration_label = ft.Text("00:00", size=12, width=50) - self._timeline_slider = ft.Slider( - min=0, - max=100, - value=0, - expand=True, - on_change=self._on_slider_change, - disabled=True, - ) - - self._row = ft.Row( - [ - self._play_btn, - self._stop_btn, - self._position_label, - self._timeline_slider, - self._duration_label, - ], - visible=False, - ) - return self._row - - def set_visible(self, visible: bool) -> None: - """Set visibility of playback controls. - - Args: - visible: Whether controls should be visible. - """ - if self._row: - self._row.visible = visible - self._state.request_update() - - def load_audio(self) -> None: - """Load session audio buffer for playback.""" - buffer = self._state.session_audio_buffer - if not buffer: - logger.warning("No audio in session buffer") - return - - # Play through SoundDevicePlayback - self._state.playback.play(buffer) - self._state.playback.pause() # Load but don't start - - # Update UI state - duration = self._state.playback.total_duration - self._state.playback_position = 0.0 - - self._state.run_on_ui_thread(lambda: self._update_loaded_state(duration)) - - def _update_loaded_state(self, duration: float) -> None: - """Update UI after audio is loaded (UI thread only).""" - if self._play_btn: - self._play_btn.disabled = False - if self._stop_btn: - self._stop_btn.disabled = False - if self._timeline_slider: - self._timeline_slider.disabled = False - self._timeline_slider.max = max(duration, 0.1) - self._timeline_slider.value = 0 - if self._duration_label: - self._duration_label.value = format_timestamp(duration) - if self._position_label: - self._position_label.value = "00:00" - - self.set_visible(True) - self._state.request_update() - - def seek(self, position: float) -> None: - """Seek to a specific position. - - Args: - position: Position in seconds. - """ - if self._state.playback.seek(position): - self._state.playback_position = position - self._state.run_on_ui_thread(self._update_position_display) - - def _on_play_click(self, e: ft.ControlEvent) -> None: - """Handle play/pause button click.""" - playback = self._state.playback - - if playback.state == PlaybackState.PLAYING: - playback.pause() - self._stop_position_updates() - self._update_play_button(playing=False) - elif playback.state == PlaybackState.PAUSED: - playback.resume() - self._start_position_updates() - self._update_play_button(playing=True) - elif buffer := self._state.session_audio_buffer: - playback.play(buffer) - self._start_position_updates() - self._update_play_button(playing=True) - - def _on_stop_click(self, e: ft.ControlEvent) -> None: - """Handle stop button click.""" - self._stop_position_updates() - self._state.playback.stop() - self._state.playback_position = 0.0 - self._update_play_button(playing=False) - self._state.run_on_ui_thread(self._update_position_display) - - def _on_slider_change(self, e: ft.ControlEvent) -> None: - """Handle timeline slider change.""" - if self._timeline_slider: - position = float(self._timeline_slider.value or 0) - self.seek(position) - - def _update_play_button(self, *, playing: bool) -> None: - """Update play button icon based on state.""" - if self._play_btn: - if playing: - self._play_btn.icon = ft.Icons.PAUSE - self._play_btn.tooltip = "Pause" - else: - self._play_btn.icon = ft.Icons.PLAY_ARROW - self._play_btn.tooltip = "Play" - self._state.request_update() - - def _start_position_updates(self) -> None: - """Start receiving position updates via callback.""" - if self._active: - return - self._active = True - self._state.playback.add_position_callback(self._on_position_update) - - def _stop_position_updates(self) -> None: - """Stop receiving position updates.""" - if not self._active: - return - self._active = False - self._state.playback.remove_position_callback(self._on_position_update) - - def _on_position_update(self, position: float) -> None: - """Handle position update from playback callback. - - Called from audio thread - schedules UI work on UI thread. - """ - if not self._active: - return - - playback = self._state.playback - - # Check if playback stopped - if playback.state == PlaybackState.STOPPED: - self._active = False - self._state.playback.remove_position_callback(self._on_position_update) - self._state.run_on_ui_thread(self._on_playback_finished) - return - - # Update position state - self._state.playback_position = position - self._state.run_on_ui_thread(self._update_position_display) - - # Notify external callback - if self._on_position_change: - try: - self._on_position_change(position) - except Exception as e: - logger.error("Position change callback error: %s", e) - - def _update_position_display(self) -> None: - """Update position display elements (UI thread only).""" - position = self._state.playback_position - - if self._position_label: - self._position_label.value = format_timestamp(position) - - if self._timeline_slider and not self._timeline_slider.disabled: - # Only update if user isn't dragging - self._timeline_slider.value = position - - self._state.request_update() - - def _on_playback_finished(self) -> None: - """Handle playback completion (UI thread only).""" - self._update_play_button(playing=False) - self._state.playback_position = 0.0 - self._update_position_display() diff --git a/src/noteflow/client/components/playback_sync.py b/src/noteflow/client/components/playback_sync.py deleted file mode 100644 index 325da9b..0000000 --- a/src/noteflow/client/components/playback_sync.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Playback-transcript synchronization controller. - -Uses playback position callbacks to update transcript highlight state. -No polling thread - receives position updates directly from SoundDevicePlayback. -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from typing import TYPE_CHECKING - -from noteflow.infrastructure.audio import PlaybackState - -if TYPE_CHECKING: - from noteflow.client.state import AppState - -logger = logging.getLogger(__name__) - - -class PlaybackSyncController: - """Synchronize playback position with transcript highlighting. - - Receives position updates via callback from SoundDevicePlayback. - Updates state.highlighted_segment_index and triggers UI updates. - """ - - def __init__( - self, - state: AppState, - on_highlight_change: Callable[[int | None], None] | None = None, - ) -> None: - """Initialize sync controller. - - Args: - state: Centralized application state. - on_highlight_change: Callback when highlighted segment changes. - """ - self._state = state - self._on_highlight_change = on_highlight_change - self._active = False - - def start(self) -> None: - """Start position sync by registering callback with playback.""" - if self._active: - return - - self._active = True - self._state.playback.add_position_callback(self._on_position_update) - logger.debug("Started playback sync controller") - - def stop(self) -> None: - """Stop position sync by unregistering callback.""" - if not self._active: - return - - self._active = False - self._state.playback.remove_position_callback(self._on_position_update) - - # Clear highlight when stopped - if self._state.highlighted_segment_index is not None: - self._state.highlighted_segment_index = None - self._state.run_on_ui_thread(self._notify_highlight_change) - - logger.debug("Stopped playback sync controller") - - def _on_position_update(self, position: float) -> None: - """Handle position update from playback callback. - - Called from audio thread - schedules UI work on UI thread. - """ - if not self._active: - return - - # Check if playback stopped - if self._state.playback.state == PlaybackState.STOPPED: - self.stop() - return - - self._update_position(position) - - def _update_position(self, position: float) -> None: - """Update state with current position and find matching segment.""" - self._state.playback_position = position - - new_index = self._state.find_segment_at_position(position) - old_index = self._state.highlighted_segment_index - - if new_index != old_index: - self._state.highlighted_segment_index = new_index - self._state.run_on_ui_thread(self._notify_highlight_change) - - def _notify_highlight_change(self) -> None: - """Notify UI of highlight change (UI thread only).""" - if self._on_highlight_change: - try: - self._on_highlight_change(self._state.highlighted_segment_index) - except Exception as e: - logger.error("Highlight change callback error: %s", e) - - self._state.request_update() - - def seek_to_segment(self, segment_index: int) -> bool: - """Seek playback to start of specified segment. - - Args: - segment_index: Index into state.transcript_segments. - - Returns: - True if seek was successful. - """ - segments = self._state.transcript_segments - if not (0 <= segment_index < len(segments)): - logger.warning("Invalid segment index: %d", segment_index) - return False - - playback = self._state.playback - segment = segments[segment_index] - - if playback.seek(segment.start_time): - self._state.highlighted_segment_index = segment_index - self._state.playback_position = segment.start_time - self._state.run_on_ui_thread(self._notify_highlight_change) - return True - - return False diff --git a/src/noteflow/client/components/recording_timer.py b/src/noteflow/client/components/recording_timer.py deleted file mode 100644 index a68a445..0000000 --- a/src/noteflow/client/components/recording_timer.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Recording timer component with background thread. - -Uses format_timestamp() from infrastructure/export/_formatting.py (not local implementation). -""" - -from __future__ import annotations - -import time -from typing import TYPE_CHECKING, Final - -import flet as ft - -from noteflow.client.components._thread_mixin import BackgroundWorkerMixin - -# REUSE existing formatting utility - do not recreate -from noteflow.infrastructure.export._formatting import format_timestamp - -if TYPE_CHECKING: - from noteflow.client.state import AppState - -TIMER_UPDATE_INTERVAL: Final[float] = 1.0 - - -class RecordingTimerComponent(BackgroundWorkerMixin): - """Recording duration timer with background thread. - - Uses format_timestamp() from export._formatting (not local implementation). - """ - - def __init__(self, state: AppState) -> None: - """Initialize timer component. - - Args: - state: Centralized application state. - """ - self._state = state - self._init_worker() - - self._dot: ft.Icon | None = None - self._label: ft.Text | None = None - self._row: ft.Row | None = None - - def build(self) -> ft.Row: - """Build timer UI elements. - - Returns: - Row containing recording dot and time label. - """ - self._dot = ft.Icon( - ft.Icons.FIBER_MANUAL_RECORD, - color=ft.Colors.RED, - size=16, - ) - self._label = ft.Text( - "00:00", - size=20, - weight=ft.FontWeight.BOLD, - color=ft.Colors.RED, - ) - self._row = ft.Row( - controls=[self._dot, self._label], - visible=False, - ) - return self._row - - def start(self) -> None: - """Start the recording timer.""" - self._state.recording_start_time = time.time() - self._state.elapsed_seconds = 0 - - if self._row: - self._row.visible = True - if self._label: - self._label.value = "00:00" - - self._start_worker(self._timer_loop, "RecordingTimer") - self._state.request_update() - - def stop(self) -> None: - """Stop the recording timer.""" - self._stop_worker(timeout=2.0) - - if self._row: - self._row.visible = False - - self._state.recording_start_time = None - self._state.request_update() - - def _timer_loop(self) -> None: - """Background timer loop.""" - while self._should_run(): - if self._state.recording_start_time is not None: - self._state.elapsed_seconds = int(time.time() - self._state.recording_start_time) - self._state.run_on_ui_thread(self._update_display) - self._wait_interval(TIMER_UPDATE_INTERVAL) - - def _update_display(self) -> None: - """Update timer display (UI thread only).""" - if not self._label: - return - - # REUSE existing format_timestamp from _formatting.py - self._label.value = format_timestamp(float(self._state.elapsed_seconds)) - self._state.request_update() diff --git a/src/noteflow/client/components/summary_panel.py b/src/noteflow/client/components/summary_panel.py deleted file mode 100644 index 815c1ce..0000000 --- a/src/noteflow/client/components/summary_panel.py +++ /dev/null @@ -1,552 +0,0 @@ -"""Summary panel component for evidence-linked meeting summaries. - -Uses existing patterns from MeetingLibraryComponent and TranscriptComponent. -Does not recreate any types - imports and uses existing domain entities. -""" - -from __future__ import annotations - -import logging -from collections.abc import Callable -from typing import TYPE_CHECKING -from uuid import UUID - -import flet as ft - -if TYPE_CHECKING: - from noteflow.application.services import SummarizationService - from noteflow.client.state import AppState - from noteflow.domain.entities import ActionItem, KeyPoint, Summary - -from noteflow.domain.value_objects import MeetingId - -logger = logging.getLogger(__name__) - -# Priority color mapping -PRIORITY_COLORS: dict[int, str] = { - 0: ft.Colors.GREY_400, # Unspecified - 1: ft.Colors.BLUE_400, # Low - 2: ft.Colors.ORANGE_400, # Medium - 3: ft.Colors.RED_400, # High -} - -PRIORITY_LABELS: dict[int, str] = { - 0: "—", - 1: "Low", - 2: "Med", - 3: "High", -} - - -class SummaryPanelComponent: - """Summary panel with evidence-linked key points and action items. - - Displays executive summary, key points with citations, and action items - with priority badges. Citation chips link back to transcript segments. - """ - - def __init__( - self, - state: AppState, - get_service: Callable[[], SummarizationService | None], - on_citation_click: Callable[[int], None] | None = None, - ) -> None: - """Initialize summary panel. - - Args: - state: Centralized application state. - get_service: Callable to get summarization service. - on_citation_click: Callback when citation chip is clicked (segment_id). - """ - self._state = state - self._get_service = get_service - self._on_citation_click = on_citation_click - - # Uncited drafts tracking - self._show_uncited: bool = False - self._original_summary: Summary | None = None - self._filtered_summary: Summary | None = None - self._uncited_key_points: int = 0 - self._uncited_action_items: int = 0 - - # UI references (set in build) - self._container: ft.Container | None = None - self._summary_text: ft.Text | None = None - self._key_points_list: ft.ListView | None = None - self._action_items_list: ft.ListView | None = None - self._generate_btn: ft.ElevatedButton | None = None - self._loading_indicator: ft.ProgressRing | None = None - self._error_text: ft.Text | None = None - self._uncited_toggle: ft.Switch | None = None - self._uncited_count_text: ft.Text | None = None - - def build(self) -> ft.Container: - """Build the summary panel UI. - - Returns: - Container with summary panel content. - """ - # Executive summary section - self._summary_text = ft.Text( - "", - size=14, - selectable=True, - ) - - # Key points list with citation chips - self._key_points_list = ft.ListView( - spacing=5, - height=150, - padding=5, - ) - - # Action items list with priority badges - self._action_items_list = ft.ListView( - spacing=5, - height=150, - padding=5, - ) - - # Generate button - self._generate_btn = ft.ElevatedButton( - "Generate Summary", - icon=ft.Icons.AUTO_AWESOME, - on_click=self._on_generate_click, - disabled=True, - ) - - # Loading/error states - self._loading_indicator = ft.ProgressRing( - visible=False, - width=20, - height=20, - ) - self._error_text = ft.Text( - "", - color=ft.Colors.RED_400, - visible=False, - size=12, - ) - - # Uncited drafts toggle - self._uncited_count_text = ft.Text( - "", - size=11, - color=ft.Colors.GREY_600, - visible=False, - ) - self._uncited_toggle = ft.Switch( - label="Show uncited", - value=False, - on_change=self._on_uncited_toggle, - visible=False, - scale=0.8, - ) - - summary_container = ft.Container( - content=self._summary_text, - padding=10, - bgcolor=ft.Colors.GREY_100, - border_radius=4, - ) - - self._container = ft.Container( - content=ft.Column( - [ - ft.Row( - [ - ft.Text("Summary", size=16, weight=ft.FontWeight.BOLD), - self._generate_btn, - self._loading_indicator, - ft.Container(expand=True), # Spacer - self._uncited_count_text, - self._uncited_toggle, - ], - alignment=ft.MainAxisAlignment.START, - spacing=10, - ), - self._error_text, - summary_container, - ft.Text("Key Points:", size=14, weight=ft.FontWeight.BOLD), - ft.Container( - content=self._key_points_list, - border=ft.border.all(1, ft.Colors.GREY_300), - border_radius=4, - ), - ft.Text("Action Items:", size=14, weight=ft.FontWeight.BOLD), - ft.Container( - content=self._action_items_list, - border=ft.border.all(1, ft.Colors.GREY_300), - border_radius=4, - ), - ], - spacing=10, - ), - visible=False, - ) - return self._container - - def set_visible(self, visible: bool) -> None: - """Set panel visibility. - - Args: - visible: Whether panel should be visible. - """ - if self._container: - self._container.visible = visible - self._state.request_update() - - def set_enabled(self, enabled: bool) -> None: - """Set generate button enabled state. - - Args: - enabled: Whether generate button should be enabled. - """ - if self._generate_btn: - self._generate_btn.disabled = not enabled - self._state.request_update() - - def _on_generate_click(self, e: ft.ControlEvent) -> None: - """Handle generate button click.""" - if self._state._page: - self._state._page.run_task(self._generate_summary) - - async def _generate_summary(self) -> None: - """Generate summary asynchronously.""" - service = self._get_service() - if not service: - self._show_error("Summarization service not available") - return - - if not self._state.current_meeting: - self._show_error("No meeting selected") - return - - if not self._state.transcript_segments: - self._show_error("No transcript segments to summarize") - return - - # Convert TranscriptSegment to domain Segment - segments = self._convert_segments() - - self._state.summary_loading = True - self._state.summary_error = None - self._update_loading_state() - - # Convert meeting id string to MeetingId - try: - meeting_uuid = UUID(str(self._state.current_meeting.id)) - except (AttributeError, ValueError) as exc: - self._show_error("Invalid meeting id") - logger.error("Invalid meeting id for summary: %s", exc) - self._state.summary_loading = False - self._state.run_on_ui_thread(self._update_loading_state) - return - - meeting_id = MeetingId(meeting_uuid) - - try: - result = await service.summarize( - meeting_id=meeting_id, - segments=segments, - ) - # Track original and filtered summaries for toggle - self._original_summary = result.result.summary - self._filtered_summary = result.filtered_summary - self._state.current_summary = result.summary - - # Calculate uncited counts - self._calculate_uncited_counts() - - self._state.run_on_ui_thread(self._render_summary) - - # Log provider info - logger.info( - "Summary generated by %s (fallback=%s)", - result.provider_used, - result.fallback_used, - ) - except Exception as exc: - logger.exception("Summarization failed") - error_msg = str(exc) - self._state.summary_error = error_msg - self._state.run_on_ui_thread(lambda msg=error_msg: self._show_error(msg)) - finally: - self._state.summary_loading = False - self._state.run_on_ui_thread(self._update_loading_state) - - def _convert_segments(self) -> list: - """Convert TranscriptSegment to domain Segment for service call. - - Returns: - List of domain Segment entities. - """ - from noteflow.domain.entities import Segment - - segments = [] - for ts in self._state.transcript_segments: - seg = Segment( - segment_id=ts.segment_id, - text=ts.text, - start_time=ts.start_time, - end_time=ts.end_time, - language=ts.language, - ) - segments.append(seg) - return segments - - def _update_loading_state(self) -> None: - """Update loading indicator visibility.""" - if self._loading_indicator: - self._loading_indicator.visible = self._state.summary_loading - if self._generate_btn: - self._generate_btn.disabled = self._state.summary_loading - self._state.request_update() - - def _show_error(self, message: str) -> None: - """Show error message. - - Args: - message: Error message to display. - """ - if self._error_text: - self._error_text.value = message - self._error_text.visible = True - self._state.request_update() - - def _clear_error(self) -> None: - """Clear error message.""" - if self._error_text: - self._error_text.value = "" - self._error_text.visible = False - self._state.request_update() - - def _render_summary(self) -> None: - """Render summary content (UI thread only).""" - summary = self._get_display_summary() - if not summary: - return - - self._clear_error() - - # Update uncited toggle visibility - self._update_uncited_ui() - - # Executive summary - if self._summary_text: - self._summary_text.value = summary.executive_summary or "No summary generated." - - # Key points - if self._key_points_list: - self._key_points_list.controls.clear() - for idx, kp in enumerate(summary.key_points): - self._key_points_list.controls.append(self._create_key_point_row(kp, idx)) - - # Action items - if self._action_items_list: - self._action_items_list.controls.clear() - for idx, ai in enumerate(summary.action_items): - self._action_items_list.controls.append(self._create_action_item_row(ai, idx)) - - self._state.request_update() - - def _create_key_point_row(self, kp: KeyPoint, index: int) -> ft.Container: - """Create a row for a key point. - - Args: - kp: Key point to display. - index: Index in the list. - - Returns: - Container with key point content. - """ - # Citation chips - citation_chips = ft.Row( - [self._create_citation_chip(sid) for sid in kp.segment_ids], - spacing=4, - ) - - # Evidence indicator - evidence_icon = ft.Icon( - ft.Icons.CHECK_CIRCLE if kp.has_evidence() else ft.Icons.HELP_OUTLINE, - size=16, - color=ft.Colors.GREEN_400 if kp.has_evidence() else ft.Colors.GREY_400, - ) - - row = ft.Row( - [ - ft.Text(f"{index + 1}.", size=12, color=ft.Colors.GREY_600, width=20), - evidence_icon, - ft.Text(kp.text, size=13, expand=True), - citation_chips, - ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.START, - ) - - return ft.Container( - content=row, - padding=ft.padding.symmetric(horizontal=8, vertical=4), - border_radius=4, - ) - - def _create_action_item_row(self, ai: ActionItem, index: int) -> ft.Container: - """Create a row for an action item. - - Args: - ai: Action item to display. - index: Index in the list. - - Returns: - Container with action item content. - """ - # Priority badge - priority_badge = self._create_priority_badge(ai.priority) - - # Assignee - assignee_text = ft.Text( - ai.assignee if ai.is_assigned() else "Unassigned", - size=11, - color=ft.Colors.BLUE_700 if ai.is_assigned() else ft.Colors.GREY_500, - italic=not ai.is_assigned(), - ) - - # Citation chips - citation_chips = ft.Row( - [self._create_citation_chip(sid) for sid in ai.segment_ids], - spacing=4, - ) - - # Evidence indicator - evidence_icon = ft.Icon( - ft.Icons.CHECK_CIRCLE if ai.has_evidence() else ft.Icons.HELP_OUTLINE, - size=16, - color=ft.Colors.GREEN_400 if ai.has_evidence() else ft.Colors.GREY_400, - ) - - row = ft.Row( - [ - ft.Text(f"{index + 1}.", size=12, color=ft.Colors.GREY_600, width=20), - priority_badge, - evidence_icon, - ft.Column( - [ - ft.Text(ai.text, size=13), - assignee_text, - ], - spacing=2, - expand=True, - ), - citation_chips, - ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.START, - ) - - return ft.Container( - content=row, - padding=ft.padding.symmetric(horizontal=8, vertical=4), - border_radius=4, - ) - - def _create_priority_badge(self, priority: int) -> ft.Container: - """Create priority indicator badge. - - Args: - priority: Priority level (0-3). - - Returns: - Container with priority badge. - """ - return ft.Container( - content=ft.Text( - PRIORITY_LABELS.get(priority, "—"), - size=10, - color=ft.Colors.WHITE, - ), - bgcolor=PRIORITY_COLORS.get(priority, ft.Colors.GREY_400), - border_radius=4, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - width=35, - alignment=ft.alignment.center, - ) - - def _create_citation_chip(self, segment_id: int) -> ft.Container: - """Create clickable citation chip. - - Args: - segment_id: Segment ID to link to. - - Returns: - Container with citation chip. - """ - return ft.Container( - content=ft.Text( - f"[#{segment_id}]", - size=11, - color=ft.Colors.BLUE_700, - ), - bgcolor=ft.Colors.BLUE_50, - border_radius=4, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - on_click=lambda _: self._handle_citation_click(segment_id), - ink=True, - ) - - def _handle_citation_click(self, segment_id: int) -> None: - """Handle citation chip click. - - Args: - segment_id: Segment ID that was clicked. - """ - if self._on_citation_click: - self._on_citation_click(segment_id) - - def _calculate_uncited_counts(self) -> None: - """Calculate number of uncited items filtered out.""" - if not self._original_summary or not self._filtered_summary: - self._uncited_key_points = 0 - self._uncited_action_items = 0 - return - - original_kp = len(self._original_summary.key_points) - filtered_kp = len(self._filtered_summary.key_points) - self._uncited_key_points = original_kp - filtered_kp - - original_ai = len(self._original_summary.action_items) - filtered_ai = len(self._filtered_summary.action_items) - self._uncited_action_items = original_ai - filtered_ai - - def _has_uncited_items(self) -> bool: - """Check if any uncited items exist.""" - return self._uncited_key_points > 0 or self._uncited_action_items > 0 - - def _on_uncited_toggle(self, e: ft.ControlEvent) -> None: - """Handle uncited drafts toggle change.""" - self._show_uncited = e.control.value - self._render_summary() - - def _update_uncited_ui(self) -> None: - """Update uncited toggle visibility and count text.""" - has_uncited = self._has_uncited_items() - - if self._uncited_toggle: - self._uncited_toggle.visible = has_uncited - - if self._uncited_count_text: - if has_uncited: - total_uncited = self._uncited_key_points + self._uncited_action_items - self._uncited_count_text.value = f"({total_uncited} hidden)" - self._uncited_count_text.visible = not self._show_uncited - else: - self._uncited_count_text.visible = False - - def _get_display_summary(self) -> Summary | None: - """Get summary to display based on toggle state. - - Returns: - Original summary if showing uncited, filtered otherwise. - """ - if self._show_uncited and self._original_summary: - return self._original_summary - return self._state.current_summary diff --git a/src/noteflow/client/components/transcript.py b/src/noteflow/client/components/transcript.py deleted file mode 100644 index d5882dd..0000000 --- a/src/noteflow/client/components/transcript.py +++ /dev/null @@ -1,408 +0,0 @@ -"""Transcript display component with click-to-seek and highlighting. - -Uses TranscriptSegment from grpc.client and format_timestamp from _formatting. -Does not recreate any types - imports and uses existing ones. -""" - -from __future__ import annotations - -import hashlib -from collections.abc import Callable -from threading import Timer -from typing import TYPE_CHECKING - -import flet as ft - -# REUSE existing formatting - do not recreate -from noteflow.infrastructure.export._formatting import format_timestamp - -# Debounce delay for search input (milliseconds) -_SEARCH_DEBOUNCE_MS = 200 - -if TYPE_CHECKING: - from noteflow.client.state import AppState - - # REUSE existing types - do not recreate - from noteflow.grpc.client import ServerInfo, TranscriptSegment - - -class TranscriptComponent: - """Transcript segment display with click-to-seek, highlighting, and search. - - Uses TranscriptSegment from grpc.client and format_timestamp from _formatting. - """ - - def __init__( - self, - state: AppState, - on_segment_click: Callable[[int], None] | None = None, - ) -> None: - """Initialize transcript component. - - Args: - state: Centralized application state. - on_segment_click: Callback when segment clicked (receives segment index). - """ - self._state = state - self._on_segment_click = on_segment_click - self._list_view: ft.ListView | None = None - self._segment_rows: list[ft.Container] = [] # All rows, use visible property to filter - self._search_field: ft.TextField | None = None - self._search_query: str = "" - self._partial_row: ft.Container | None = None # Live partial at bottom - self._search_timer: Timer | None = None # Debounce timer for search - - def build(self) -> ft.Column: - """Build transcript list view with search. - - Returns: - Column with search field and bordered ListView. - """ - self._search_field = ft.TextField( - label="Search transcript", - prefix_icon=ft.Icons.SEARCH, - on_change=self._on_search_change, - dense=True, - height=40, - ) - - self._list_view = ft.ListView( - spacing=10, - padding=10, - auto_scroll=False, # We control scrolling for sync - height=260, - ) - self._segment_rows.clear() - - return ft.Column( - [ - self._search_field, - ft.Container( - content=self._list_view, - border=ft.border.all(1, ft.Colors.GREY_400), - border_radius=8, - ), - ], - spacing=5, - ) - - def add_segment(self, segment: TranscriptSegment) -> None: - """Add transcript segment to display. - - For final segments, adds to transcript list. - For partials, updates the live partial row at bottom. - - Args: - segment: Transcript segment from server. - """ - if segment.is_final: - # Clear partial text when we get a final - self._state.current_partial_text = "" - self._state.transcript_segments.append(segment) - self._state.run_on_ui_thread(lambda: self._render_final_segment(segment)) - else: - # Update partial text - self._state.current_partial_text = segment.text - self._state.run_on_ui_thread(lambda: self._render_partial(segment.text)) - - def display_server_info(self, info: ServerInfo) -> None: - """Display server info in transcript area. - - Args: - info: Server info from connection. - """ - self._state.run_on_ui_thread(lambda: self._render_server_info(info)) - - def clear(self) -> None: - """Clear all transcript segments and partials.""" - # Cancel pending search timer - if self._search_timer is not None: - self._search_timer.cancel() - self._search_timer = None - - self._state.clear_transcript() - self._segment_rows.clear() - self._partial_row = None - self._search_query = "" - if self._search_field: - self._search_field.value = "" - if self._list_view: - self._list_view.controls.clear() - self._state.request_update() - - def _on_search_change(self, e: ft.ControlEvent) -> None: - """Handle search field change with debounce. - - Args: - e: Control event with new search value. - """ - self._search_query = (e.control.value or "").lower() - - # Cancel pending timer - if self._search_timer is not None: - self._search_timer.cancel() - - # Start new debounce timer - self._search_timer = Timer( - _SEARCH_DEBOUNCE_MS / 1000.0, - self._apply_search_filter, - ) - self._search_timer.start() - - def _apply_search_filter(self) -> None: - """Apply search filter to existing rows via visibility toggle.""" - self._state.run_on_ui_thread(self._toggle_row_visibility) - - def _toggle_row_visibility(self) -> None: - """Toggle visibility of rows based on search query (UI thread only).""" - if not self._list_view: - return - - query = self._search_query - for idx, container in enumerate(self._segment_rows): - if idx >= len(self._state.transcript_segments): - continue - segment = self._state.transcript_segments[idx] - matches = not query or query in segment.text.lower() - container.visible = matches - - self._state.request_update() - - def _rerender_all_segments(self) -> None: - """Re-render all segments with current search filter.""" - if not self._list_view: - return - - self._list_view.controls.clear() - self._segment_rows.clear() - - query = self._search_query - for idx, segment in enumerate(self._state.transcript_segments): - container = self._create_segment_row(segment, idx) - # Set visibility based on search query - container.visible = not query or query in segment.text.lower() - self._segment_rows.append(container) - self._list_view.controls.append(container) - - self._state.request_update() - - def _render_final_segment(self, segment: TranscriptSegment) -> None: - """Render final segment with click handler (UI thread only). - - Args: - segment: Transcript segment to render. - """ - if not self._list_view: - return - - # Remove partial row if present (final replaces partial) - if self._partial_row and self._partial_row in self._list_view.controls: - self._list_view.controls.remove(self._partial_row) - self._partial_row = None - - # Use the actual index from state (segments are appended before rendering) - segment_index = len(self._state.transcript_segments) - 1 - container = self._create_segment_row(segment, segment_index) - - # Set visibility based on search query during live rendering - query = self._search_query - container.visible = not query or query in segment.text.lower() - - self._segment_rows.append(container) - self._list_view.controls.append(container) - self._state.request_update() - - def _render_partial(self, text: str) -> None: - """Render or update the partial text row at the bottom (UI thread only). - - Args: - text: Partial transcript text. - """ - if not self._list_view or not text: - return - - # Create or update partial row - partial_content = ft.Row( - [ - ft.Text("[LIVE]", size=11, color=ft.Colors.BLUE_400, width=120, italic=True), - ft.Text( - text, - size=14, - color=ft.Colors.GREY_500, - weight=ft.FontWeight.W_300, - italic=True, - expand=True, - ), - ] - ) - - if self._partial_row: - # Update existing row - self._partial_row.content = partial_content - else: - # Create new row - self._partial_row = ft.Container( - content=partial_content, - padding=5, - border_radius=4, - bgcolor=ft.Colors.BLUE_50, - ) - self._list_view.controls.append(self._partial_row) - - self._state.request_update() - - def _create_segment_row(self, segment: TranscriptSegment, segment_index: int) -> ft.Container: - """Create a segment row container. - - Args: - segment: Transcript segment to render. - segment_index: Index for click handling. - - Returns: - Container with segment content. - """ - # REUSE existing format_timestamp from _formatting.py - # Format as time range for transcript display - time_str = ( - f"[{format_timestamp(segment.start_time)} - {format_timestamp(segment.end_time)}]" - ) - - # Style based on finality - color = ft.Colors.BLACK if segment.is_final else ft.Colors.GREY_600 - weight = ft.FontWeight.NORMAL if segment.is_final else ft.FontWeight.W_300 - - # Build row content with optional speaker label - row_controls: list[ft.Control] = [ - ft.Text(time_str, size=11, color=ft.Colors.GREY_500, width=120), - ] - - # Add speaker label if present - if segment.speaker_id: - speaker_color = self._get_speaker_color(segment.speaker_id) - row_controls.append( - ft.Container( - content=ft.Text( - segment.speaker_id, - size=10, - color=ft.Colors.WHITE, - weight=ft.FontWeight.BOLD, - ), - bgcolor=speaker_color, - border_radius=10, - padding=ft.padding.symmetric(horizontal=6, vertical=2), - margin=ft.margin.only(right=8), - ) - ) - - row_controls.append( - ft.Text( - segment.text, - size=14, - color=color, - weight=weight, - expand=True, - ) - ) - - row = ft.Row(row_controls) - - # Wrap in container for click handling and highlighting - return ft.Container( - content=row, - padding=5, - border_radius=4, - on_click=lambda e, idx=segment_index: self._handle_click(idx), - ink=True, - ) - - def _get_speaker_color(self, speaker_id: str) -> str: - """Get consistent color for a speaker. - - Args: - speaker_id: Speaker identifier. - - Returns: - Color string for the speaker label. - """ - # Use hash to get consistent color index - colors = [ - ft.Colors.BLUE_400, - ft.Colors.GREEN_400, - ft.Colors.PURPLE_400, - ft.Colors.ORANGE_400, - ft.Colors.TEAL_400, - ft.Colors.PINK_400, - ft.Colors.INDIGO_400, - ft.Colors.AMBER_600, - ] - digest = hashlib.md5(speaker_id.encode("utf-8")).hexdigest() - return colors[int(digest, 16) % len(colors)] - - def _handle_click(self, segment_index: int) -> None: - """Handle segment row click. - - Args: - segment_index: Index of clicked segment. - """ - if self._on_segment_click: - self._on_segment_click(segment_index) - - def _render_server_info(self, info: ServerInfo) -> None: - """Render server info (UI thread only). - - Args: - info: Server info to display. - """ - if not self._list_view: - return - - asr_status = "ready" if info.asr_ready else "not ready" - info_text = ( - f"Connected to server v{info.version} | " - f"ASR: {info.asr_model} ({asr_status}) | " - f"Active meetings: {info.active_meetings}" - ) - - self._list_view.controls.append( - ft.Text( - info_text, - size=12, - color=ft.Colors.GREEN_700, - italic=True, - ) - ) - self._state.request_update() - - def update_highlight(self, highlighted_index: int | None) -> None: - """Update visual highlight on segments. - - Args: - highlighted_index: Index of segment to highlight, or None to clear. - """ - for idx, container in enumerate(self._segment_rows): - if idx == highlighted_index: - container.bgcolor = ft.Colors.YELLOW_100 - container.border = ft.border.all(1, ft.Colors.YELLOW_700) - else: - container.bgcolor = None - container.border = None - - # Scroll to highlighted segment - if highlighted_index is not None: - self._scroll_to_segment(highlighted_index) - - self._state.request_update() - - def _scroll_to_segment(self, segment_index: int) -> None: - """Scroll ListView to show specified segment. - - Args: - segment_index: Index of segment to scroll to. - """ - if not self._list_view or segment_index >= len(self._segment_rows): - return - - # Estimate row height for scroll calculation - estimated_row_height = 50 - offset = segment_index * estimated_row_height - self._list_view.scroll_to(offset=offset, duration=200) diff --git a/src/noteflow/client/components/vu_meter.py b/src/noteflow/client/components/vu_meter.py deleted file mode 100644 index bd244ee..0000000 --- a/src/noteflow/client/components/vu_meter.py +++ /dev/null @@ -1,98 +0,0 @@ -"""VU meter component for audio level visualization. - -Uses RmsLevelProvider from AppState (not a new instance). -""" - -from __future__ import annotations - -import time -from typing import TYPE_CHECKING, Final - -import flet as ft -import numpy as np -from numpy.typing import NDArray - -if TYPE_CHECKING: - from noteflow.client.state import AppState - -# Throttle UI updates to 20 fps (50ms interval) -VU_UPDATE_INTERVAL: Final[float] = 0.05 - - -class VuMeterComponent: - """Audio level visualization component. - - Uses RmsLevelProvider from AppState (not a new instance). - """ - - def __init__(self, state: AppState) -> None: - """Initialize VU meter component. - - Args: - state: Centralized application state with level_provider. - """ - self._state = state - # REUSE level_provider from state - do not create new instance - self._progress_bar: ft.ProgressBar | None = None - self._label: ft.Text | None = None - self._last_update_time: float = 0.0 - - def build(self) -> ft.Row: - """Build VU meter UI elements. - - Returns: - Row containing progress bar and level label. - """ - self._progress_bar = ft.ProgressBar( - value=0, - width=300, - bar_height=20, - color=ft.Colors.GREEN, - bgcolor=ft.Colors.GREY_300, - ) - self._label = ft.Text("-60 dB", size=12, width=60) - - return ft.Row( - [ - ft.Text("Level:", size=12), - self._progress_bar, - self._label, - ] - ) - - def on_audio_frames(self, frames: NDArray[np.float32]) -> None: - """Process incoming audio frames for level metering. - - Uses state.level_provider.get_db() - existing RmsLevelProvider method. - Throttled to VU_UPDATE_INTERVAL to avoid excessive UI updates. - - Args: - frames: Audio samples as float32 array. - """ - now = time.time() - if now - self._last_update_time < VU_UPDATE_INTERVAL: - return # Throttle: skip update if within interval - - self._last_update_time = now - - # REUSE existing RmsLevelProvider from state - db_level = self._state.level_provider.get_db(frames) - self._state.current_db_level = db_level - self._state.run_on_ui_thread(self._update_display) - - def _update_display(self) -> None: - """Update VU meter display (UI thread only).""" - if not self._progress_bar or not self._label: - return - - db = self._state.current_db_level - # Convert dB to 0-1 range (-60 to 0 dB) - normalized = max(0.0, min(1.0, (db + 60) / 60)) - - self._progress_bar.value = normalized - self._progress_bar.color = ( - ft.Colors.RED if db > -6 else ft.Colors.YELLOW if db > -20 else ft.Colors.GREEN - ) - self._label.value = f"{db:.0f} dB" - - self._state.request_update() diff --git a/src/noteflow/client/state.py b/src/noteflow/client/state.py deleted file mode 100644 index 7f8f052..0000000 --- a/src/noteflow/client/state.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Centralized application state for NoteFlow client. - -Composes existing types from grpc.client and infrastructure.audio. -Does not recreate any dataclasses - imports and uses existing ones. -""" - -import logging -from collections.abc import Callable -from dataclasses import dataclass, field - -import flet as ft - -# REUSE existing types - do not recreate -from noteflow.domain.entities import Summary -from noteflow.domain.triggers import TriggerDecision -from noteflow.grpc.client import AnnotationInfo, MeetingInfo, ServerInfo, TranscriptSegment -from noteflow.infrastructure.audio import ( - RmsLevelProvider, - SoundDevicePlayback, - TimestampedAudio, -) - -logger = logging.getLogger(__name__) - -# Callback type aliases (follow NoteFlowClient pattern from grpc/client.py) -OnTranscriptCallback = Callable[[TranscriptSegment], None] -OnConnectionCallback = Callable[[bool, str], None] - - -@dataclass -class AppState: - """Centralized application state for NoteFlow client. - - Composes existing types from grpc.client and infrastructure.audio. - All state is centralized here for component access. - """ - - # Connection state - server_address: str = "localhost:50051" - connected: bool = False - server_info: ServerInfo | None = None # REUSE existing type - - # Recording state - recording: bool = False - current_meeting: MeetingInfo | None = None # REUSE existing type - recording_start_time: float | None = None - elapsed_seconds: int = 0 - - # Audio state (REUSE existing RmsLevelProvider) - level_provider: RmsLevelProvider = field(default_factory=RmsLevelProvider) - current_db_level: float = -60.0 - - # Transcript state (REUSE existing TranscriptSegment) - transcript_segments: list[TranscriptSegment] = field(default_factory=list) - current_partial_text: str = "" # Live partial transcript (not yet final) - - # Playback state (REUSE existing SoundDevicePlayback) - playback: SoundDevicePlayback = field(default_factory=SoundDevicePlayback) - playback_position: float = 0.0 - session_audio_buffer: list[TimestampedAudio] = field(default_factory=list) - - # Transcript sync state - highlighted_segment_index: int | None = None - - # Annotations state (REUSE existing AnnotationInfo) - annotations: list[AnnotationInfo] = field(default_factory=list) - - # Meeting library state (REUSE existing MeetingInfo) - meetings: list[MeetingInfo] = field(default_factory=list) - selected_meeting: MeetingInfo | None = None - - # Trigger state (REUSE existing TriggerDecision) - trigger_enabled: bool = True - trigger_pending: bool = False # True when prompt is shown - trigger_decision: TriggerDecision | None = None # Last trigger decision - - # Summary state (REUSE existing Summary entity) - current_summary: Summary | None = None - summary_loading: bool = False - summary_error: str | None = None - - # UI page reference (private) - _page: ft.Page | None = field(default=None, repr=False) - - def set_page(self, page: ft.Page) -> None: - """Set page reference for thread-safe updates. - - Args: - page: Flet page instance. - """ - self._page = page - - def request_update(self) -> None: - """Request UI update from any thread. - - Safe to call from background threads. - """ - if self._page: - self._page.update() - - def run_on_ui_thread(self, callback: Callable[[], None]) -> None: - """Schedule callback on the UI event loop safely. - - Follows NoteFlowClient callback pattern with error handling. - - Args: - callback: Function to execute on the UI event loop. - """ - if not self._page: - return - - try: - if hasattr(self._page, "run_task"): - - async def _run() -> None: - callback() - - self._page.run_task(_run) - else: - self._page.run_thread(callback) - except Exception as e: - logger.error("UI thread callback error: %s", e) - - def clear_transcript(self) -> None: - """Clear all transcript segments and partial text.""" - self.transcript_segments.clear() - self.current_partial_text = "" - - def reset_recording_state(self) -> None: - """Reset recording-related state.""" - self.recording = False - self.current_meeting = None - self.recording_start_time = None - self.elapsed_seconds = 0 - - def clear_session_audio(self) -> None: - """Clear session audio buffer and reset playback state.""" - self.session_audio_buffer.clear() - self.playback_position = 0.0 - - def find_segment_at_position(self, position: float) -> int | None: - """Find segment index containing the given position using binary search. - - Args: - position: Time in seconds. - - Returns: - Index of segment containing position, or None if not found. - """ - segments = self.transcript_segments - if not segments: - return None - - left, right = 0, len(segments) - 1 - - while left <= right: - mid = (left + right) // 2 - segment = segments[mid] - - if segment.start_time <= position <= segment.end_time: - return mid - if position < segment.start_time: - right = mid - 1 - else: - left = mid + 1 - - return None diff --git a/tests/client/conftest.py b/tests/client/conftest.py deleted file mode 100644 index aa45250..0000000 --- a/tests/client/conftest.py +++ /dev/null @@ -1,548 +0,0 @@ -"""Shared fixtures for client component tests.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field -from enum import Enum, auto -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -import numpy as np -import pytest -from numpy.typing import NDArray - -if TYPE_CHECKING: - pass - - -# ============================================================================ -# Mock Enums -# ============================================================================ - - -class MockPlaybackState(Enum): - """Mock PlaybackState for tests.""" - - STOPPED = auto() - PLAYING = auto() - PAUSED = auto() - - -# ============================================================================ -# Mock Data Classes (mirror grpc.client types) -# ============================================================================ - - -@dataclass -class MockTranscriptSegment: - """Mock TranscriptSegment for testing.""" - - segment_id: int = 0 - text: str = "" - start_time: float = 0.0 - end_time: float = 1.0 - language: str = "en" - is_final: bool = True - speaker_id: str = "" - speaker_confidence: float = 0.0 - - -@dataclass -class MockServerInfo: - """Mock ServerInfo for testing.""" - - version: str = "1.0.0" - asr_model: str = "base" - asr_ready: bool = True - uptime_seconds: float = 3600.0 - active_meetings: int = 0 - diarization_enabled: bool = False - diarization_ready: bool = False - - -@dataclass -class MockMeetingInfo: - """Mock MeetingInfo for testing.""" - - id: str = "meeting-123" - title: str = "Test Meeting" - state: str = "stopped" - created_at: float = 1700000000.0 - started_at: float = 1700000000.0 - ended_at: float = 1700000060.0 - duration_seconds: float = 60.0 - segment_count: int = 5 - - -@dataclass -class MockAnnotationInfo: - """Mock AnnotationInfo for testing.""" - - id: str = "annotation-1" - meeting_id: str = "meeting-123" - annotation_type: str = "note" - text: str = "Test annotation" - start_time: float = 10.0 - end_time: float = 10.0 - segment_ids: list[int] = field(default_factory=list) - created_at: float = 1700000010.0 - - -@dataclass -class MockExportResult: - """Mock ExportResult for testing.""" - - content: str = "# Test Export\n\nContent here." - format_name: str = "markdown" - file_extension: str = "md" - - -@dataclass -class MockDiarizationResult: - """Mock DiarizationResult for testing.""" - - job_id: str = "job-123" - status: str = "completed" - segments_updated: int = 10 - speaker_ids: list[str] = field(default_factory=lambda: ["SPEAKER_00", "SPEAKER_01"]) - error_message: str = "" - - @property - def success(self) -> bool: - """Check if diarization succeeded.""" - return self.status == "completed" and not self.error_message - - @property - def is_terminal(self) -> bool: - """Check if job reached a terminal state.""" - return self.status in {"completed", "failed"} - - -@dataclass -class MockRenameSpeakerResult: - """Mock RenameSpeakerResult for testing.""" - - segments_updated: int = 5 - success: bool = True - - -# ============================================================================ -# Mock Infrastructure Classes -# ============================================================================ - - -class MockRmsLevelProvider: - """Mock RmsLevelProvider for tests.""" - - def __init__(self, db_value: float = -40.0) -> None: - """Initialize mock level provider. - - Args: - db_value: dB value to return from get_db(). - """ - self._db_value = db_value - - def get_db(self, frames: NDArray[np.float32]) -> float: - """Return configured dB value. - - Args: - frames: Audio frames (ignored). - - Returns: - Configured dB value. - """ - return self._db_value - - def set_db(self, db_value: float) -> None: - """Set the dB value to return. - - Args: - db_value: New dB value. - """ - self._db_value = db_value - - -@dataclass -class MockPlayback: - """Mock SoundDevicePlayback for tests.""" - - state: MockPlaybackState = MockPlaybackState.STOPPED - total_duration: float = 0.0 - _position_callbacks: list[Callable[[float], None]] = field(default_factory=list) - _last_seek_position: float = 0.0 - _play_called: bool = False - _pause_called: bool = False - _resume_called: bool = False - _stop_called: bool = False - - def play(self, buffer: list) -> None: - """Mock play method.""" - self._play_called = True - self.state = MockPlaybackState.PLAYING - if buffer: - self.total_duration = len(buffer) * 0.1 # Estimate duration - - def pause(self) -> None: - """Mock pause method.""" - self._pause_called = True - self.state = MockPlaybackState.PAUSED - - def resume(self) -> None: - """Mock resume method.""" - self._resume_called = True - self.state = MockPlaybackState.PLAYING - - def stop(self) -> None: - """Mock stop method.""" - self._stop_called = True - self.state = MockPlaybackState.STOPPED - - def seek(self, position: float) -> bool: - """Mock seek method. - - Args: - position: Position to seek to. - - Returns: - True if seek succeeded. - """ - self._last_seek_position = position - return True - - def add_position_callback(self, callback: Callable[[float], None]) -> None: - """Add position callback. - - Args: - callback: Callback to add. - """ - self._position_callbacks.append(callback) - - def remove_position_callback(self, callback: Callable[[float], None]) -> None: - """Remove position callback. - - Args: - callback: Callback to remove. - """ - if callback in self._position_callbacks: - self._position_callbacks.remove(callback) - - def simulate_position_update(self, position: float) -> None: - """Simulate position update for testing. - - Args: - position: Position to report. - """ - for callback in self._position_callbacks: - callback(position) - - -# ============================================================================ -# MockAppState -# ============================================================================ - - -@dataclass -class MockAppState: - """Mock AppState for client component tests. - - Provides synchronous execution of callbacks for deterministic testing. - """ - - # Connection state - server_address: str = "localhost:50051" - connected: bool = False - recording: bool = False - server_info: MockServerInfo | None = None - - # Meeting state - current_meeting: MockMeetingInfo | None = None - selected_meeting: MockMeetingInfo | None = None - meetings: list[MockMeetingInfo] = field(default_factory=list) - - # Transcript state - transcript_segments: list[MockTranscriptSegment] = field(default_factory=list) - current_partial_text: str = "" - - # Annotation state - annotations: list[MockAnnotationInfo] = field(default_factory=list) - - # Recording state - recording_start_time: float | None = None - elapsed_seconds: int = 0 - - # Audio state - current_db_level: float = -60.0 - session_audio_buffer: list = field(default_factory=list) - - # Playback state - playback_position: float = 0.0 - - # Infrastructure mocks (created via default_factory) - level_provider: MockRmsLevelProvider = field(default_factory=MockRmsLevelProvider) - playback: MockPlayback = field(default_factory=MockPlayback) - - # Page reference - _page: MagicMock | None = None - - def request_update(self) -> None: - """No-op for tests.""" - - def run_on_ui_thread(self, callback: Callable[[], None]) -> None: - """Execute callback immediately for tests (synchronous). - - Args: - callback: Callback to execute. - """ - callback() - - def clear_transcript(self) -> None: - """Clear transcript segments and partial text.""" - self.transcript_segments.clear() - self.current_partial_text = "" - - -# ============================================================================ -# Factory Functions -# ============================================================================ - - -def create_mock_server_info( - version: str = "1.0.0", - asr_model: str = "base", - asr_ready: bool = True, - uptime_seconds: float = 3600.0, - active_meetings: int = 0, - diarization_enabled: bool = False, - diarization_ready: bool = False, -) -> MockServerInfo: - """Create mock ServerInfo with specified values. - - Args: - version: Server version. - asr_model: ASR model name. - asr_ready: Whether ASR is ready. - uptime_seconds: Server uptime. - active_meetings: Active meeting count. - diarization_enabled: Whether diarization is enabled. - diarization_ready: Whether diarization is ready. - - Returns: - MockServerInfo instance. - """ - return MockServerInfo( - version=version, - asr_model=asr_model, - asr_ready=asr_ready, - uptime_seconds=uptime_seconds, - active_meetings=active_meetings, - diarization_enabled=diarization_enabled, - diarization_ready=diarization_ready, - ) - - -def create_mock_meeting_info( - id: str = "meeting-123", - title: str = "Test Meeting", - state: str = "stopped", - created_at: float = 1700000000.0, - started_at: float = 1700000000.0, - ended_at: float = 1700000060.0, - duration_seconds: float = 60.0, - segment_count: int = 5, -) -> MockMeetingInfo: - """Create mock MeetingInfo with specified values. - - Args: - id: Meeting ID. - title: Meeting title. - state: Meeting state. - created_at: Creation timestamp. - started_at: Start timestamp. - ended_at: End timestamp. - duration_seconds: Duration in seconds. - segment_count: Segment count. - - Returns: - MockMeetingInfo instance. - """ - return MockMeetingInfo( - id=id, - title=title, - state=state, - created_at=created_at, - started_at=started_at, - ended_at=ended_at, - duration_seconds=duration_seconds, - segment_count=segment_count, - ) - - -def create_mock_annotation_info( - id: str = "annotation-1", - meeting_id: str = "meeting-123", - annotation_type: str = "note", - text: str = "Test annotation", - start_time: float = 10.0, - end_time: float = 10.0, - segment_ids: list[int] | None = None, - created_at: float = 1700000010.0, -) -> MockAnnotationInfo: - """Create mock AnnotationInfo with specified values. - - Args: - id: Annotation ID. - meeting_id: Meeting ID. - annotation_type: Annotation type. - text: Annotation text. - start_time: Start timestamp. - end_time: End timestamp. - segment_ids: Related segment IDs. - created_at: Creation timestamp. - - Returns: - MockAnnotationInfo instance. - """ - return MockAnnotationInfo( - id=id, - meeting_id=meeting_id, - annotation_type=annotation_type, - text=text, - start_time=start_time, - end_time=end_time, - segment_ids=segment_ids or [], - created_at=created_at, - ) - - -def create_mock_transcript_segment( - segment_id: int = 0, - text: str = "Test segment", - start_time: float = 0.0, - end_time: float = 1.0, - language: str = "en", - is_final: bool = True, - speaker_id: str = "", - speaker_confidence: float = 0.0, -) -> MockTranscriptSegment: - """Create mock TranscriptSegment with specified values. - - Args: - segment_id: Segment ID. - text: Segment text. - start_time: Start time. - end_time: End time. - language: Language code. - is_final: Whether segment is final. - speaker_id: Speaker ID. - speaker_confidence: Speaker confidence. - - Returns: - MockTranscriptSegment instance. - """ - return MockTranscriptSegment( - segment_id=segment_id, - text=text, - start_time=start_time, - end_time=end_time, - language=language, - is_final=is_final, - speaker_id=speaker_id, - speaker_confidence=speaker_confidence, - ) - - -# ============================================================================ -# Pytest Fixtures -# ============================================================================ - - -@pytest.fixture -def mock_app_state() -> MockAppState: - """Create fresh MockAppState instance. - - Returns: - MockAppState instance. - """ - return MockAppState() - - -@pytest.fixture -def mock_grpc_client() -> MagicMock: - """Create mock NoteFlowClient with all methods. - - Returns: - MagicMock configured as NoteFlowClient. - """ - client = MagicMock() - client.connect.return_value = True - client.disconnect.return_value = None - client.get_server_info.return_value = create_mock_server_info() - client.list_meetings.return_value = [] - client.export_transcript.return_value = None - client.add_annotation.return_value = None - client.get_meeting_segments.return_value = [] - client.refine_speaker_diarization.return_value = None - client.get_diarization_job_status.return_value = None - client.rename_speaker.return_value = None - return client - - -@pytest.fixture -def mock_meeting_info() -> MockMeetingInfo: - """Create sample MockMeetingInfo. - - Returns: - MockMeetingInfo instance. - """ - return create_mock_meeting_info() - - -@pytest.fixture -def mock_server_info() -> MockServerInfo: - """Create sample MockServerInfo. - - Returns: - MockServerInfo instance. - """ - return create_mock_server_info() - - -@pytest.fixture -def mock_page() -> MagicMock: - """Create Flet page mock with dialog support. - - Returns: - MagicMock configured as Flet page. - """ - page = MagicMock() - page.dialog = None - page.overlay = [] - page.update = MagicMock() - page.run_thread = MagicMock() - return page - - -@pytest.fixture -def mock_app_state_with_page(mock_app_state: MockAppState, mock_page: MagicMock) -> MockAppState: - """Create MockAppState with page attached. - - Args: - mock_app_state: Base mock state. - mock_page: Mock page. - - Returns: - MockAppState with page. - """ - mock_app_state._page = mock_page - return mock_app_state - - -@pytest.fixture -def sample_audio_frames() -> NDArray[np.float32]: - """Create sample audio frames for testing. - - Returns: - NumPy array of audio samples. - """ - return np.zeros(1600, dtype=np.float32) # 100ms at 16kHz diff --git a/tests/client/test_annotation_toolbar.py b/tests/client/test_annotation_toolbar.py deleted file mode 100644 index 0b33814..0000000 --- a/tests/client/test_annotation_toolbar.py +++ /dev/null @@ -1,476 +0,0 @@ -"""Tests for AnnotationToolbarComponent.""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -import flet as ft -import pytest - -from noteflow.client.components.annotation_toolbar import AnnotationToolbarComponent - -from .conftest import ( - MockAnnotationInfo, - MockAppState, - MockMeetingInfo, - create_mock_annotation_info, - create_mock_meeting_info, -) - - -class TestAnnotationToolbarBuild: - """Tests for AnnotationToolbarComponent.build().""" - - def test_build_returns_flet_row(self, mock_app_state: MockAppState) -> None: - """build() should return ft.Row.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - result = component.build() - - assert isinstance(result, ft.Row) - - def test_build_contains_four_buttons(self, mock_app_state: MockAppState) -> None: - """build() should create four annotation buttons.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._action_btn is not None - assert component._decision_btn is not None - assert component._note_btn is not None - assert component._risk_btn is not None - - def test_buttons_have_correct_labels(self, mock_app_state: MockAppState) -> None: - """Buttons should have correct text labels.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._action_btn is not None - assert component._action_btn.text == "Action Item" - assert component._decision_btn is not None - assert component._decision_btn.text == "Decision" - assert component._note_btn is not None - assert component._note_btn.text == "Note" - assert component._risk_btn is not None - assert component._risk_btn.text == "Risk" - - def test_buttons_have_correct_icons(self, mock_app_state: MockAppState) -> None: - """Buttons should have correct icons.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._action_btn is not None - assert component._action_btn.icon == ft.Icons.CHECK_CIRCLE_OUTLINE - assert component._decision_btn is not None - assert component._decision_btn.icon == ft.Icons.GAVEL - assert component._note_btn is not None - assert component._note_btn.icon == ft.Icons.NOTE_ADD - assert component._risk_btn is not None - assert component._risk_btn.icon == ft.Icons.WARNING_AMBER - - def test_buttons_initially_disabled(self, mock_app_state: MockAppState) -> None: - """All buttons should be disabled initially.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._action_btn is not None - assert component._action_btn.disabled is True - assert component._decision_btn is not None - assert component._decision_btn.disabled is True - assert component._note_btn is not None - assert component._note_btn.disabled is True - assert component._risk_btn is not None - assert component._risk_btn.disabled is True - - def test_row_initially_hidden(self, mock_app_state: MockAppState) -> None: - """Row should be hidden by default.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._row is not None - assert component._row.visible is False - - -class TestAnnotationToolbarVisibility: - """Tests for visibility control.""" - - def test_set_visible_true_shows_row(self, mock_app_state: MockAppState) -> None: - """set_visible(True) should make row visible.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - component.build() - - component.set_visible(True) - - assert component._row is not None - assert component._row.visible is True - - def test_set_visible_false_hides_row(self, mock_app_state: MockAppState) -> None: - """set_visible(False) should hide row.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - component.build() - component.set_visible(True) - - component.set_visible(False) - - assert component._row is not None - assert component._row.visible is False - - def test_set_enabled_true_enables_buttons(self, mock_app_state: MockAppState) -> None: - """set_enabled(True) should enable all buttons.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - component.build() - - component.set_enabled(True) - - assert component._action_btn is not None - assert component._action_btn.disabled is False - assert component._decision_btn is not None - assert component._decision_btn.disabled is False - assert component._note_btn is not None - assert component._note_btn.disabled is False - assert component._risk_btn is not None - assert component._risk_btn.disabled is False - - def test_set_enabled_false_disables_buttons(self, mock_app_state: MockAppState) -> None: - """set_enabled(False) should disable all buttons.""" - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - component.build() - component.set_enabled(True) - - component.set_enabled(False) - - assert component._action_btn is not None - assert component._action_btn.disabled is True - assert component._decision_btn is not None - assert component._decision_btn.disabled is True - assert component._note_btn is not None - assert component._note_btn.disabled is True - assert component._risk_btn is not None - assert component._risk_btn.disabled is True - - -class TestAnnotationToolbarDialogs: - """Tests for annotation dialog display.""" - - def test_show_annotation_dialog_sets_type( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_annotation_dialog should set current annotation type.""" - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_annotation_dialog("action_item") - - assert component._current_annotation_type == "action_item" - - def test_show_annotation_dialog_creates_dialog( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_annotation_dialog should create dialog.""" - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_annotation_dialog("note") - - assert component._dialog is not None - assert isinstance(component._dialog, ft.AlertDialog) - - def test_show_annotation_dialog_creates_text_field( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_annotation_dialog should create text field.""" - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_annotation_dialog("decision") - - assert component._text_field is not None - assert isinstance(component._text_field, ft.TextField) - assert component._text_field.multiline is True - - @pytest.mark.parametrize( - ("annotation_type", "expected_title"), - [ - ("action_item", "Add Action Item"), - ("decision", "Add Decision"), - ("note", "Add Note"), - ("risk", "Add Risk"), - ], - ) - def test_dialog_title_matches_type( - self, - mock_app_state_with_page: MockAppState, - annotation_type: str, - expected_title: str, - ) -> None: - """Dialog title should match annotation type.""" - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_annotation_dialog(annotation_type) - - assert component._dialog is not None - assert component._dialog.title is not None - assert isinstance(component._dialog.title, ft.Text) - assert component._dialog.title.value == expected_title - - def test_close_dialog_sets_open_false( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_close_dialog should set dialog.open to False.""" - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - component._show_annotation_dialog("note") - assert component._dialog is not None - component._dialog.open = True - - component._close_dialog() - - assert component._dialog is not None - assert component._dialog.open is False - - -class TestAnnotationToolbarSubmission: - """Tests for annotation submission.""" - - def test_submit_calls_add_annotation( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation should call client.add_annotation.""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - mock_grpc_client.add_annotation.return_value = create_mock_annotation_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = "Test annotation text" - - component._submit_annotation(MagicMock()) - - mock_grpc_client.add_annotation.assert_called_once() - - def test_submit_uses_correct_params( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation should pass correct parameters.""" - meeting = create_mock_meeting_info(id="meeting-456") - mock_app_state_with_page.current_meeting = meeting - mock_app_state_with_page.elapsed_seconds = 30 - mock_grpc_client.add_annotation.return_value = create_mock_annotation_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("action_item") - assert component._text_field is not None - component._text_field.value = "Test action" - - component._submit_annotation(MagicMock()) - - mock_grpc_client.add_annotation.assert_called_once_with( - meeting_id="meeting-456", - annotation_type="action_item", - text="Test action", - start_time=30.0, - end_time=30.0, - ) - - def test_submit_uses_playback_position_when_available( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation should use playback_position if > 0.""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - mock_app_state_with_page.playback_position = 45.5 - mock_app_state_with_page.elapsed_seconds = 100 - mock_grpc_client.add_annotation.return_value = create_mock_annotation_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = "Test note" - - component._submit_annotation(MagicMock()) - - call_kwargs = mock_grpc_client.add_annotation.call_args.kwargs - assert call_kwargs["start_time"] == 45.5 - assert call_kwargs["end_time"] == 45.5 - - def test_submit_uses_elapsed_seconds_when_playback_zero( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation should fall back to elapsed_seconds.""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - mock_app_state_with_page.playback_position = 0.0 - mock_app_state_with_page.elapsed_seconds = 75 - mock_grpc_client.add_annotation.return_value = create_mock_annotation_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = "Test note" - - component._submit_annotation(MagicMock()) - - call_kwargs = mock_grpc_client.add_annotation.call_args.kwargs - assert call_kwargs["start_time"] == 75.0 - assert call_kwargs["end_time"] == 75.0 - - def test_submit_creates_point_annotation( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation should create point annotation (start == end).""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - mock_grpc_client.add_annotation.return_value = create_mock_annotation_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("decision") - assert component._text_field is not None - component._text_field.value = "Test decision" - - component._submit_annotation(MagicMock()) - - call_kwargs = mock_grpc_client.add_annotation.call_args.kwargs - assert call_kwargs["start_time"] == call_kwargs["end_time"] - - def test_submit_appends_to_state_annotations( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation should append result to state.annotations.""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - annotation_result = create_mock_annotation_info(id="new-annotation") - mock_grpc_client.add_annotation.return_value = annotation_result - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = "Test note" - - component._submit_annotation(MagicMock()) - - assert len(mock_app_state_with_page.annotations) == 1 - - def test_submit_without_client_logs_warning( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_submit_annotation without client should not crash.""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = "Test note" - - # Should not raise - component._submit_annotation(MagicMock()) - - def test_submit_without_meeting_logs_warning( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation without meeting should not crash.""" - mock_app_state_with_page.current_meeting = None - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = "Test note" - - # Should not raise - component._submit_annotation(MagicMock()) - mock_grpc_client.add_annotation.assert_not_called() - - def test_submit_empty_text_does_nothing( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_submit_annotation with empty text should not call client.""" - mock_app_state_with_page.current_meeting = create_mock_meeting_info() - - component = AnnotationToolbarComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_annotation_dialog("note") - assert component._text_field is not None - component._text_field.value = " " # Whitespace only - - component._submit_annotation(MagicMock()) - - mock_grpc_client.add_annotation.assert_not_called() - - -class TestAnnotationToolbarTimestamp: - """Tests for timestamp calculation.""" - - def test_get_current_timestamp_prefers_playback( - self, mock_app_state: MockAppState - ) -> None: - """_get_current_timestamp should prefer playback_position.""" - mock_app_state.playback_position = 25.5 - mock_app_state.elapsed_seconds = 100 - - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - result = component._get_current_timestamp() - - assert result == 25.5 - - def test_get_current_timestamp_fallback_elapsed( - self, mock_app_state: MockAppState - ) -> None: - """_get_current_timestamp should fall back to elapsed_seconds.""" - mock_app_state.playback_position = 0.0 - mock_app_state.elapsed_seconds = 42 - - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - result = component._get_current_timestamp() - - assert result == 42.0 - - def test_get_current_timestamp_returns_float( - self, mock_app_state: MockAppState - ) -> None: - """_get_current_timestamp should always return float.""" - mock_app_state.playback_position = 0.0 - mock_app_state.elapsed_seconds = 60 - - component = AnnotationToolbarComponent(mock_app_state, get_client=lambda: None) - - result = component._get_current_timestamp() - - assert isinstance(result, float) diff --git a/tests/client/test_async_mixin.py b/tests/client/test_async_mixin.py deleted file mode 100644 index 493e658..0000000 --- a/tests/client/test_async_mixin.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Tests for AsyncOperationMixin.""" - -from __future__ import annotations - -import asyncio -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from noteflow.client.components._async_mixin import AsyncOperationMixin - - -class ConcreteComponent(AsyncOperationMixin[str]): - """Concrete implementation for testing.""" - - def __init__(self, page: MagicMock | None = None) -> None: - self._page = page - - -class TestAsyncOperationMixin: - """Tests for AsyncOperationMixin.""" - - @pytest.fixture - def mock_page(self) -> MagicMock: - """Create mock Flet page.""" - page = MagicMock() - - def _run_task(fn): - try: - loop = asyncio.get_running_loop() - return loop.create_task(fn()) - except RuntimeError: - # No running loop (sync tests); run immediately - return asyncio.run(fn()) - - page.run_task = MagicMock(side_effect=_run_task) - return page - - @pytest.fixture - def component(self, mock_page: MagicMock) -> ConcreteComponent: - """Create component with mock page.""" - return ConcreteComponent(page=mock_page) - - @pytest.mark.asyncio - async def test_run_async_operation_success_calls_callbacks( - self, component: ConcreteComponent - ) -> None: - """Successful operation calls on_success and set_loading.""" - operation = AsyncMock(return_value="result") - on_success = MagicMock() - on_error = MagicMock() - set_loading = MagicMock() - - result = await component.run_async_operation( - operation=operation, - on_success=on_success, - on_error=on_error, - set_loading=set_loading, - ) - - await asyncio.sleep(0) - - assert result == "result" - operation.assert_awaited_once() - on_success.assert_called_once_with("result") - on_error.assert_not_called() - # Loading: True then False - assert set_loading.call_count == 2 - set_loading.assert_any_call(True) - set_loading.assert_any_call(False) - - @pytest.mark.asyncio - async def test_run_async_operation_error_calls_on_error( - self, component: ConcreteComponent - ) -> None: - """Failed operation calls on_error and returns None.""" - operation = AsyncMock(side_effect=ValueError("test error")) - on_success = MagicMock() - on_error = MagicMock() - set_loading = MagicMock() - - result = await component.run_async_operation( - operation=operation, - on_success=on_success, - on_error=on_error, - set_loading=set_loading, - ) - - await asyncio.sleep(0) - - assert result is None - on_success.assert_not_called() - on_error.assert_called_once_with("test error") - # Loading: True then False (finally block) - assert set_loading.call_count == 2 - - @pytest.mark.asyncio - async def test_run_async_operation_always_clears_loading( - self, component: ConcreteComponent - ) -> None: - """Loading state always cleared in finally block.""" - operation = AsyncMock(side_effect=RuntimeError("boom")) - set_loading = MagicMock() - - await component.run_async_operation( - operation=operation, - on_success=MagicMock(), - on_error=MagicMock(), - set_loading=set_loading, - ) - - await asyncio.sleep(0) - - # Final call should be set_loading(False) - assert set_loading.call_args_list[-1][0][0] is False - - def test_dispatch_ui_no_page_is_noop(self) -> None: - """Dispatch with no page does nothing.""" - component = ConcreteComponent(page=None) - callback = MagicMock() - - # Should not raise - component._dispatch_ui(callback) - - callback.assert_not_called() - - def test_dispatch_ui_with_page_calls_run_task( - self, component: ConcreteComponent, mock_page: MagicMock - ) -> None: - """Dispatch with page calls page.run_task.""" - callback = MagicMock() - - component._dispatch_ui(callback) - - mock_page.run_task.assert_called_once() - callback.assert_called_once() diff --git a/tests/client/test_connection_panel.py b/tests/client/test_connection_panel.py deleted file mode 100644 index 0236b80..0000000 --- a/tests/client/test_connection_panel.py +++ /dev/null @@ -1,603 +0,0 @@ -"""Tests for ConnectionPanelComponent.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import flet as ft -import pytest - -from noteflow.client.components.connection_panel import ( - RECONNECT_ATTEMPTS, - RECONNECT_DELAY_SECONDS, - ConnectionPanelComponent, -) - -from .conftest import MockAppState, MockServerInfo, create_mock_server_info - - -class TestConnectionPanelBuild: - """Tests for ConnectionPanelComponent.build().""" - - def test_build_returns_flet_column(self, mock_app_state: MockAppState) -> None: - """build() should return ft.Column.""" - component = ConnectionPanelComponent(mock_app_state) - - result = component.build() - - assert isinstance(result, ft.Column) - - def test_build_contains_server_field(self, mock_app_state: MockAppState) -> None: - """build() should create server address field.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._server_field is not None - assert isinstance(component._server_field, ft.TextField) - - def test_build_contains_connect_button(self, mock_app_state: MockAppState) -> None: - """build() should create connect button.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._connect_btn is not None - assert isinstance(component._connect_btn, ft.ElevatedButton) - - def test_build_contains_status_text(self, mock_app_state: MockAppState) -> None: - """build() should create status text.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._status_text is not None - assert isinstance(component._status_text, ft.Text) - - def test_build_contains_server_info_text( - self, mock_app_state: MockAppState - ) -> None: - """build() should create server info text.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._server_info_text is not None - assert isinstance(component._server_info_text, ft.Text) - - def test_initial_button_shows_connect(self, mock_app_state: MockAppState) -> None: - """Button should show 'Connect' initially.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._connect_btn is not None - assert component._connect_btn.text == "Connect" - - def test_initial_button_shows_cloud_off_icon( - self, mock_app_state: MockAppState - ) -> None: - """Button should show cloud_off icon initially.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._connect_btn is not None - assert component._connect_btn.icon == ft.Icons.CLOUD_OFF - - def test_server_field_has_default_value(self, mock_app_state: MockAppState) -> None: - """Server field should use state.server_address.""" - mock_app_state.server_address = "custom:8080" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._server_field is not None - assert component._server_field.value == "custom:8080" - - def test_initial_status_text(self, mock_app_state: MockAppState) -> None: - """Status should show 'Not connected'.""" - component = ConnectionPanelComponent(mock_app_state) - - component.build() - - assert component._status_text is not None - assert component._status_text.value == "Not connected" - - -class TestConnectionPanelServerChange: - """Tests for server address changes.""" - - def test_on_server_change_updates_state(self, mock_app_state: MockAppState) -> None: - """Server field change should update state.server_address.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - event = MagicMock() - event.control.value = "newserver:9090" - component._on_server_change(event) - - assert mock_app_state.server_address == "newserver:9090" - - -class TestConnectionPanelButtonState: - """Tests for button state updates.""" - - def test_update_button_state_when_connected( - self, mock_app_state: MockAppState - ) -> None: - """Button should show 'Disconnect' when connected.""" - mock_app_state.connected = True - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component.update_button_state() - - assert component._connect_btn is not None - assert component._connect_btn.text == "Disconnect" - assert component._connect_btn.icon == ft.Icons.CLOUD_DONE - - def test_update_button_state_when_disconnected( - self, mock_app_state: MockAppState - ) -> None: - """Button should show 'Connect' when disconnected.""" - mock_app_state.connected = False - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component.update_button_state() - - assert component._connect_btn is not None - assert component._connect_btn.text == "Connect" - assert component._connect_btn.icon == ft.Icons.CLOUD_OFF - - -class TestConnectionPanelClient: - """Tests for client property.""" - - def test_client_initially_none(self, mock_app_state: MockAppState) -> None: - """client property should be None initially.""" - component = ConnectionPanelComponent(mock_app_state) - - assert component.client is None - - -class TestConnectionPanelDisconnect: - """Tests for disconnect behavior.""" - - def test_disconnect_clears_client(self, mock_app_state: MockAppState) -> None: - """disconnect() should clear client.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - - component.disconnect() - - assert component._client is None - - def test_disconnect_updates_state(self, mock_app_state: MockAppState) -> None: - """disconnect() should update state.connected.""" - mock_app_state.connected = True - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - - component.disconnect() - - assert mock_app_state.connected is False - - def test_disconnect_clears_server_info(self, mock_app_state: MockAppState) -> None: - """disconnect() should clear state.server_info.""" - mock_app_state.server_info = create_mock_server_info() - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - - component.disconnect() - - assert mock_app_state.server_info is None - - def test_disconnect_updates_button_state( - self, mock_app_state: MockAppState - ) -> None: - """disconnect() should update button to 'Connect'.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - assert component._connect_btn is not None - component._connect_btn.text = "Disconnect" - - component.disconnect() - - assert component._connect_btn is not None - assert component._connect_btn.text == "Connect" - - def test_disconnect_calls_callback(self, mock_app_state: MockAppState) -> None: - """disconnect() should invoke on_disconnected callback.""" - callback = MagicMock() - component = ConnectionPanelComponent(mock_app_state, on_disconnected=callback) - component.build() - component._client = MagicMock() - - component.disconnect() - - callback.assert_called_once() - - def test_disconnect_sets_manual_flag(self, mock_app_state: MockAppState) -> None: - """disconnect() should set _manual_disconnect flag.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - - component.disconnect() - - assert component._manual_disconnect is True - - def test_disconnect_disables_auto_reconnect( - self, mock_app_state: MockAppState - ) -> None: - """disconnect() should disable auto reconnect.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - component._auto_reconnect_enabled = True - - component.disconnect() - - assert component._auto_reconnect_enabled is False - - -class TestConnectionPanelConnect: - """Tests for connection behavior.""" - - @patch("noteflow.client.components.connection_panel.NoteFlowClient") - def test_connect_creates_client( - self, mock_client_class: MagicMock, mock_app_state: MockAppState - ) -> None: - """_connect() should create NoteFlowClient.""" - mock_client = MagicMock() - mock_client.connect.return_value = True - mock_client.get_server_info.return_value = create_mock_server_info() - mock_client_class.return_value = mock_client - - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._connect() - - mock_client_class.assert_called_once() - - @patch("noteflow.client.components.connection_panel.NoteFlowClient") - def test_connect_success_updates_state( - self, mock_client_class: MagicMock, mock_app_state: MockAppState - ) -> None: - """Successful connection should update state.""" - mock_client = MagicMock() - mock_client.connect.return_value = True - mock_client.get_server_info.return_value = create_mock_server_info() - mock_client_class.return_value = mock_client - - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._connect() - - assert mock_app_state.connected is True - assert mock_app_state.server_info is not None - - @patch("noteflow.client.components.connection_panel.NoteFlowClient") - def test_connect_failure_shows_error( - self, mock_client_class: MagicMock, mock_app_state: MockAppState - ) -> None: - """Failed connection should show error status.""" - mock_client = MagicMock() - mock_client.connect.return_value = False - mock_client_class.return_value = mock_client - - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._connect() - - assert component._status_text is not None - assert component._status_text.value is not None - assert "failed" in component._status_text.value.lower() - - -class TestConnectionPanelConnectSuccess: - """Tests for successful connection handling.""" - - def test_on_connect_success_enables_auto_reconnect( - self, mock_app_state: MockAppState - ) -> None: - """_on_connect_success should enable auto reconnect.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - server_info = create_mock_server_info() - component._on_connect_success(server_info) - - assert component._auto_reconnect_enabled is True - - def test_on_connect_success_updates_button( - self, mock_app_state: MockAppState - ) -> None: - """_on_connect_success should update button.""" - mock_app_state.connected = True - component = ConnectionPanelComponent(mock_app_state) - component.build() - - server_info = create_mock_server_info() - component._on_connect_success(server_info) - - assert component._connect_btn is not None - assert component._connect_btn.text == "Disconnect" - - def test_on_connect_success_shows_server_info( - self, mock_app_state: MockAppState - ) -> None: - """_on_connect_success should display server info.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - server_info = create_mock_server_info(version="2.0.0", asr_model="large") - component._on_connect_success(server_info) - - assert component._server_info_text is not None - assert component._server_info_text.value is not None - assert "2.0.0" in component._server_info_text.value - assert "large" in component._server_info_text.value - - def test_on_connect_success_invokes_callback( - self, mock_app_state: MockAppState - ) -> None: - """_on_connect_success should invoke on_connected callback.""" - callback = MagicMock() - component = ConnectionPanelComponent(mock_app_state, on_connected=callback) - component.build() - component._client = MagicMock() - - server_info = create_mock_server_info() - component._on_connect_success(server_info) - - callback.assert_called_once_with(component._client, server_info) - - -class TestConnectionPanelCallbacks: - """Tests for callback invocation.""" - - def test_on_connected_callback_receives_client_and_info( - self, mock_app_state: MockAppState - ) -> None: - """on_connected should receive client and server info.""" - callback = MagicMock() - component = ConnectionPanelComponent(mock_app_state, on_connected=callback) - component.build() - mock_client = MagicMock() - component._client = mock_client - - server_info = create_mock_server_info() - component._on_connect_success(server_info) - - call_args = callback.call_args - assert call_args[0][0] is mock_client - assert call_args[0][1] is server_info - - def test_on_disconnected_callback_invoked( - self, mock_app_state: MockAppState - ) -> None: - """on_disconnected should be invoked on disconnect.""" - callback = MagicMock() - component = ConnectionPanelComponent(mock_app_state, on_disconnected=callback) - component.build() - component._client = MagicMock() - - component.disconnect() - - callback.assert_called_once() - - def test_on_connection_change_callback_forwarded( - self, mock_app_state: MockAppState - ) -> None: - """on_connection_change should be forwarded.""" - callback = MagicMock() - component = ConnectionPanelComponent( - mock_app_state, on_connection_change_callback=callback - ) - component.build() - component._auto_reconnect_enabled = True - - component._handle_connection_change(True, "Connected") - - callback.assert_called_once() - - -class TestConnectionPanelReconnection: - """Tests for reconnection behavior (mocked threading).""" - - def test_start_reconnect_loop_sets_flag( - self, mock_app_state: MockAppState - ) -> None: - """_start_reconnect_loop should set in_progress flag.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._reconnect_in_progress = False - - with patch("threading.Thread") as mock_thread: - mock_thread.return_value.start = MagicMock() - component._start_reconnect_loop("test message") - - assert component._reconnect_in_progress is True - - def test_cancel_reconnect_sets_stop_event( - self, mock_app_state: MockAppState - ) -> None: - """_cancel_reconnect should set stop event.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._reconnect_stop_event.clear() - - component._cancel_reconnect() - - assert component._reconnect_stop_event.is_set() - - def test_reconnect_worker_without_client_exits_early( - self, mock_app_state: MockAppState - ) -> None: - """_reconnect_worker without client should exit.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = None - component._reconnect_in_progress = True - - component._reconnect_worker("test") - - assert component._reconnect_in_progress is False - - @patch("time.sleep") - def test_reconnect_worker_respects_stop_event( - self, mock_sleep: MagicMock, mock_app_state: MockAppState - ) -> None: - """_reconnect_worker should stop on stop event.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - component._reconnect_stop_event.set() - component._reconnect_in_progress = True - - component._reconnect_worker("test") - - assert component._reconnect_in_progress is False - - def test_attempt_reconnect_without_client_returns_false( - self, mock_app_state: MockAppState - ) -> None: - """_attempt_reconnect without client should return False.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = None - - result = component._attempt_reconnect() - - assert result is False - - def test_manual_disconnect_disables_auto_reconnect( - self, mock_app_state: MockAppState - ) -> None: - """Manual disconnect should prevent auto reconnect.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - - component.disconnect() - - assert component._manual_disconnect is True - assert component._auto_reconnect_enabled is False - - -class TestConnectionPanelConnectionChange: - """Tests for connection state change handling.""" - - def test_handle_connection_change_connected_updates_state( - self, mock_app_state: MockAppState - ) -> None: - """_handle_connection_change(True) should update state.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._handle_connection_change(True, "Connected successfully") - - assert mock_app_state.connected is True - - def test_handle_connection_change_disconnected_updates_state( - self, mock_app_state: MockAppState - ) -> None: - """_handle_connection_change(False) should update state.""" - mock_app_state.connected = True - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._manual_disconnect = True - - component._handle_connection_change(False, "Disconnected") - - assert mock_app_state.connected is False - - def test_handle_connection_change_suppressed_does_nothing( - self, mock_app_state: MockAppState - ) -> None: - """_handle_connection_change should ignore when suppressed.""" - mock_app_state.connected = False - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._suppress_connection_events = True - - component._handle_connection_change(True, "Connected") - - # State should not change when suppressed - assert mock_app_state.connected is False - - def test_handle_connection_change_enables_auto_reconnect( - self, mock_app_state: MockAppState - ) -> None: - """_handle_connection_change(True) should enable auto reconnect.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._handle_connection_change(True, "Connected") - - assert component._auto_reconnect_enabled is True - - -class TestConnectionPanelClickHandler: - """Tests for connect/disconnect button click.""" - - def test_on_connect_click_when_connected_disconnects( - self, mock_app_state: MockAppState - ) -> None: - """Clicking when connected should disconnect.""" - mock_app_state.connected = True - component = ConnectionPanelComponent(mock_app_state) - component.build() - component._client = MagicMock() - - component._on_connect_click(MagicMock()) - - assert component._client is None - - @patch("threading.Thread") - def test_on_connect_click_when_disconnected_starts_connect( - self, mock_thread: MagicMock, mock_app_state: MockAppState - ) -> None: - """Clicking when disconnected should start connection.""" - mock_app_state.connected = False - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._on_connect_click(MagicMock()) - - mock_thread.assert_called_once() - mock_thread.return_value.start.assert_called_once() - - -class TestConnectionPanelStatusUpdate: - """Tests for status text updates.""" - - def test_update_status_changes_text(self, mock_app_state: MockAppState) -> None: - """_update_status should change status text.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._update_status("New status", ft.Colors.BLUE) - - assert component._status_text is not None - assert component._status_text.value == "New status" - - def test_update_status_changes_color(self, mock_app_state: MockAppState) -> None: - """_update_status should change text color.""" - component = ConnectionPanelComponent(mock_app_state) - component.build() - - component._update_status("Status", ft.Colors.RED) - - assert component._status_text is not None - assert component._status_text.color == ft.Colors.RED diff --git a/tests/client/test_meeting_library.py b/tests/client/test_meeting_library.py deleted file mode 100644 index c19af73..0000000 --- a/tests/client/test_meeting_library.py +++ /dev/null @@ -1,684 +0,0 @@ -"""Tests for MeetingLibraryComponent.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import flet as ft -import pytest - -from noteflow.client.components.meeting_library import MeetingLibraryComponent - -from .conftest import ( - MockAppState, - MockDiarizationResult, - MockExportResult, - MockMeetingInfo, - MockRenameSpeakerResult, - MockTranscriptSegment, - create_mock_meeting_info, - create_mock_transcript_segment, -) - - -class TestMeetingLibraryBuild: - """Tests for MeetingLibraryComponent.build().""" - - def test_build_returns_flet_column(self, mock_app_state: MockAppState) -> None: - """build() should return ft.Column.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - result = component.build() - - assert isinstance(result, ft.Column) - - def test_build_contains_search_field(self, mock_app_state: MockAppState) -> None: - """build() should create search field.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._search_field is not None - assert isinstance(component._search_field, ft.TextField) - - def test_build_contains_list_view(self, mock_app_state: MockAppState) -> None: - """build() should create list view.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._list_view is not None - assert isinstance(component._list_view, ft.ListView) - - def test_build_contains_export_button(self, mock_app_state: MockAppState) -> None: - """build() should create export button.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._export_btn is not None - assert isinstance(component._export_btn, ft.ElevatedButton) - - def test_build_contains_analyze_button(self, mock_app_state: MockAppState) -> None: - """build() should create analyze button.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._analyze_btn is not None - assert isinstance(component._analyze_btn, ft.ElevatedButton) - - def test_build_contains_rename_button(self, mock_app_state: MockAppState) -> None: - """build() should create rename button.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._rename_btn is not None - assert isinstance(component._rename_btn, ft.ElevatedButton) - - def test_build_contains_refresh_button(self, mock_app_state: MockAppState) -> None: - """build() should create refresh button.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._refresh_btn is not None - assert isinstance(component._refresh_btn, ft.IconButton) - - def test_buttons_initially_disabled(self, mock_app_state: MockAppState) -> None: - """Action buttons should be disabled initially.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - - component.build() - - assert component._export_btn is not None - assert component._export_btn.disabled is True - assert component._analyze_btn is not None - assert component._analyze_btn.disabled is True - assert component._rename_btn is not None - assert component._rename_btn.disabled is True - - -class TestMeetingLibraryRefresh: - """Tests for refresh_meetings().""" - - def test_refresh_calls_list_meetings( - self, mock_app_state: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """refresh_meetings() should call client.list_meetings().""" - component = MeetingLibraryComponent( - mock_app_state, get_client=lambda: mock_grpc_client - ) - component.build() - - component.refresh_meetings() - - mock_grpc_client.list_meetings.assert_called_once_with(limit=50) - - def test_refresh_populates_list_view( - self, mock_app_state: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """refresh_meetings() should populate list view.""" - meetings = [ - create_mock_meeting_info(id="1", title="Meeting 1"), - create_mock_meeting_info(id="2", title="Meeting 2"), - ] - mock_grpc_client.list_meetings.return_value = meetings - - component = MeetingLibraryComponent( - mock_app_state, get_client=lambda: mock_grpc_client - ) - component.build() - - component.refresh_meetings() - - assert component._list_view is not None - assert len(component._list_view.controls) == 2 - - def test_refresh_without_client_does_not_crash( - self, mock_app_state: MockAppState - ) -> None: - """refresh_meetings() without client should not crash.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - - # Should not raise - component.refresh_meetings() - - def test_refresh_updates_state_meetings( - self, mock_app_state: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """refresh_meetings() should update state.meetings.""" - meetings = [create_mock_meeting_info(id="1", title="Test")] - mock_grpc_client.list_meetings.return_value = meetings - - component = MeetingLibraryComponent( - mock_app_state, get_client=lambda: mock_grpc_client - ) - component.build() - - component.refresh_meetings() - - assert len(mock_app_state.meetings) == 1 - - -class TestMeetingLibrarySearch: - """Tests for search filtering.""" - - def test_search_filters_meetings_by_title( - self, mock_app_state: MockAppState - ) -> None: - """Search should filter meetings by title.""" - mock_app_state.meetings = [ - create_mock_meeting_info(id="1", title="Project Review"), - create_mock_meeting_info(id="2", title="Team Standup"), - create_mock_meeting_info(id="3", title="Project Planning"), - ] - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - assert component._search_field is not None - component._search_field.value = "project" - - component._render_meetings() - - assert component._list_view is not None - assert len(component._list_view.controls) == 2 - - def test_search_case_insensitive(self, mock_app_state: MockAppState) -> None: - """Search should be case insensitive.""" - mock_app_state.meetings = [ - create_mock_meeting_info(id="1", title="PROJECT Review"), - ] - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - assert component._search_field is not None - component._search_field.value = "project" - - component._render_meetings() - - assert component._list_view is not None - assert len(component._list_view.controls) == 1 - - def test_search_empty_shows_all(self, mock_app_state: MockAppState) -> None: - """Empty search should show all meetings.""" - mock_app_state.meetings = [ - create_mock_meeting_info(id="1", title="Meeting 1"), - create_mock_meeting_info(id="2", title="Meeting 2"), - ] - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - assert component._search_field is not None - component._search_field.value = "" - - component._render_meetings() - - assert component._list_view is not None - assert len(component._list_view.controls) == 2 - - def test_search_no_matches_shows_empty(self, mock_app_state: MockAppState) -> None: - """Search with no matches should show empty list.""" - mock_app_state.meetings = [ - create_mock_meeting_info(id="1", title="Meeting 1"), - ] - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - assert component._search_field is not None - component._search_field.value = "nonexistent" - - component._render_meetings() - - assert component._list_view is not None - assert len(component._list_view.controls) == 0 - - -class TestMeetingLibrarySelection: - """Tests for meeting selection.""" - - def test_meeting_click_updates_state(self, mock_app_state: MockAppState) -> None: - """Clicking meeting should update state.selected_meeting.""" - meeting = create_mock_meeting_info(id="selected-meeting") - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - - component._on_meeting_click(meeting) - - assert mock_app_state.selected_meeting is not None - assert mock_app_state.selected_meeting.id == "selected-meeting" - - def test_meeting_click_enables_export_button( - self, mock_app_state: MockAppState - ) -> None: - """Clicking meeting should enable export button.""" - meeting = create_mock_meeting_info() - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - - component._on_meeting_click(meeting) - - assert component._export_btn is not None - assert component._export_btn.disabled is False - - def test_meeting_click_enables_analyze_for_stopped_meeting( - self, mock_app_state: MockAppState - ) -> None: - """Clicking stopped meeting should enable analyze button.""" - meeting = create_mock_meeting_info(state="stopped") - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - - component._on_meeting_click(meeting) - - assert component._analyze_btn is not None - assert component._analyze_btn.disabled is False - - def test_meeting_click_disables_analyze_for_recording_meeting( - self, mock_app_state: MockAppState - ) -> None: - """Clicking recording meeting should disable analyze button.""" - meeting = create_mock_meeting_info(state="recording") - - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - - component._on_meeting_click(meeting) - - assert component._analyze_btn is not None - assert component._analyze_btn.disabled is True - - def test_meeting_click_invokes_callback( - self, mock_app_state: MockAppState - ) -> None: - """Clicking meeting should invoke on_meeting_selected.""" - callback = MagicMock() - meeting = create_mock_meeting_info() - - component = MeetingLibraryComponent( - mock_app_state, get_client=lambda: None, on_meeting_selected=callback - ) - component.build() - - component._on_meeting_click(meeting) - - callback.assert_called_once_with(meeting) - - -class TestMeetingLibraryExport: - """Tests for export functionality.""" - - def test_show_export_dialog_creates_dialog( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_export_dialog should create dialog.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_export_dialog(MagicMock()) - - assert component._export_dialog is not None - assert isinstance(component._export_dialog, ft.AlertDialog) - - def test_show_export_dialog_creates_format_dropdown( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_export_dialog should create format dropdown.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_export_dialog(MagicMock()) - - assert component._format_dropdown is not None - assert isinstance(component._format_dropdown, ft.Dropdown) - - def test_export_calls_client( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_export should call client.export_transcript.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1") - mock_grpc_client.export_transcript.return_value = MockExportResult() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_export_dialog(MagicMock()) - assert component._format_dropdown is not None - component._format_dropdown.value = "markdown" - - component._do_export(MagicMock()) - - mock_grpc_client.export_transcript.assert_called_once_with("m1", "markdown") - - def test_export_without_meeting_does_nothing( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_export without selected meeting should not call client.""" - mock_app_state_with_page.selected_meeting = None - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - - component._do_export(MagicMock()) - - mock_grpc_client.export_transcript.assert_not_called() - - -class TestMeetingLibraryDiarization: - """Tests for speaker diarization functionality.""" - - def test_show_analyze_dialog_creates_dialog( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_analyze_dialog should create dialog.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info( - state="stopped" - ) - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_analyze_dialog(MagicMock()) - - assert component._analyze_dialog is not None - assert isinstance(component._analyze_dialog, ft.AlertDialog) - - def test_show_analyze_dialog_creates_num_speakers_field( - self, mock_app_state_with_page: MockAppState - ) -> None: - """_show_analyze_dialog should create number of speakers field.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info( - state="stopped" - ) - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: None - ) - component.build() - - component._show_analyze_dialog(MagicMock()) - - assert component._num_speakers_field is not None - assert isinstance(component._num_speakers_field, ft.TextField) - - def test_analyze_calls_refine_diarization( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_analyze should call client.refine_speaker_diarization.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1") - mock_grpc_client.refine_speaker_diarization.return_value = MockDiarizationResult() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_analyze_dialog(MagicMock()) - - component._do_analyze(MagicMock()) - - mock_grpc_client.refine_speaker_diarization.assert_called_once() - - def test_analyze_parses_num_speakers( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_analyze should parse num_speakers from field.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1") - mock_grpc_client.refine_speaker_diarization.return_value = MockDiarizationResult() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._show_analyze_dialog(MagicMock()) - assert component._num_speakers_field is not None - component._num_speakers_field.value = "3" - - component._do_analyze(MagicMock()) - - call_args = mock_grpc_client.refine_speaker_diarization.call_args - assert call_args[0][1] == 3 - - @pytest.mark.parametrize( - ("state", "expected"), - [ - ("stopped", True), - ("completed", True), - ("error", True), - ("recording", False), - ("starting", False), - ], - ) - def test_can_refine_speakers_checks_meeting_state( - self, state: str, expected: bool - ) -> None: - """_can_refine_speakers should check meeting state.""" - meeting = create_mock_meeting_info(state=state) - - result = MeetingLibraryComponent._can_refine_speakers(meeting) - - assert result is expected - - def test_show_analysis_progress_updates_button( - self, mock_app_state: MockAppState - ) -> None: - """_show_analysis_progress should update button text.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - - component._show_analysis_progress("Running...") - - assert component._analyze_btn is not None - assert component._analyze_btn.text == "Running..." - assert component._analyze_btn.disabled is True - - def test_format_job_status_queued(self) -> None: - """_format_job_status should format queued status.""" - result = MeetingLibraryComponent._format_job_status("queued") - - assert result == "Queued..." - - def test_format_job_status_running(self) -> None: - """_format_job_status should format running status.""" - result = MeetingLibraryComponent._format_job_status("running") - - assert result == "Refining..." - - -class TestMeetingLibrarySpeakerRename: - """Tests for speaker rename functionality.""" - - def test_show_rename_dialog_creates_dialog( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_show_rename_dialog should create dialog.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info( - state="stopped" - ) - segments = [ - create_mock_transcript_segment(speaker_id="SPEAKER_00"), - create_mock_transcript_segment(speaker_id="SPEAKER_01"), - ] - mock_grpc_client.get_meeting_segments.return_value = segments - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - - component._show_rename_dialog(MagicMock()) - - assert component._rename_dialog is not None - assert isinstance(component._rename_dialog, ft.AlertDialog) - - def test_show_rename_dialog_creates_fields_per_speaker( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_show_rename_dialog should create field per speaker.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info( - state="stopped" - ) - segments = [ - create_mock_transcript_segment(speaker_id="SPEAKER_00"), - create_mock_transcript_segment(speaker_id="SPEAKER_01"), - create_mock_transcript_segment(speaker_id="SPEAKER_00"), - ] - mock_grpc_client.get_meeting_segments.return_value = segments - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - - component._show_rename_dialog(MagicMock()) - - assert len(component._rename_fields) == 2 - - def test_no_speakers_shows_message( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_show_rename_dialog with no speakers should show message.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info( - state="stopped" - ) - segments = [create_mock_transcript_segment(speaker_id="")] - mock_grpc_client.get_meeting_segments.return_value = segments - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - - component._show_rename_dialog(MagicMock()) - - # Should show message dialog instead of rename dialog - assert component._rename_dialog is None - - def test_rename_calls_client_per_speaker( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_rename should call client.rename_speaker for each.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1") - mock_grpc_client.rename_speaker.return_value = MockRenameSpeakerResult() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._rename_fields = { - "SPEAKER_00": MagicMock(value="Alice"), - "SPEAKER_01": MagicMock(value="Bob"), - } - - component._do_rename(MagicMock()) - - assert mock_grpc_client.rename_speaker.call_count == 2 - - def test_rename_skips_empty_names( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_rename should skip empty names.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1") - mock_grpc_client.rename_speaker.return_value = MockRenameSpeakerResult() - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._rename_fields = { - "SPEAKER_00": MagicMock(value="Alice"), - "SPEAKER_01": MagicMock(value=""), # Empty - } - - component._do_rename(MagicMock()) - - mock_grpc_client.rename_speaker.assert_called_once() - - def test_rename_skips_unchanged_names( - self, mock_app_state_with_page: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_do_rename should skip unchanged names.""" - mock_app_state_with_page.selected_meeting = create_mock_meeting_info(id="m1") - - component = MeetingLibraryComponent( - mock_app_state_with_page, get_client=lambda: mock_grpc_client - ) - component.build() - component._rename_fields = { - "SPEAKER_00": MagicMock(value="SPEAKER_00"), # Unchanged - } - - component._do_rename(MagicMock()) - - mock_grpc_client.rename_speaker.assert_not_called() - - -class TestMeetingLibraryDialogClose: - """Tests for dialog close handlers.""" - - def test_close_export_dialog(self, mock_app_state: MockAppState) -> None: - """_close_export_dialog should set open to False.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - component._export_dialog = MagicMock() - component._export_dialog.open = True - - component._close_export_dialog() - - assert component._export_dialog.open is False - - def test_close_analyze_dialog(self, mock_app_state: MockAppState) -> None: - """_close_analyze_dialog should set open to False.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - component._analyze_dialog = MagicMock() - component._analyze_dialog.open = True - - component._close_analyze_dialog() - - assert component._analyze_dialog.open is False - - def test_close_rename_dialog(self, mock_app_state: MockAppState) -> None: - """_close_rename_dialog should set open to False.""" - component = MeetingLibraryComponent(mock_app_state, get_client=lambda: None) - component.build() - component._rename_dialog = MagicMock() - component._rename_dialog.open = True - - component._close_rename_dialog() - - assert component._rename_dialog.open is False - - -class TestMeetingLibraryRefreshClick: - """Tests for refresh button click.""" - - def test_on_refresh_click_calls_refresh( - self, mock_app_state: MockAppState, mock_grpc_client: MagicMock - ) -> None: - """_on_refresh_click should call refresh_meetings.""" - component = MeetingLibraryComponent( - mock_app_state, get_client=lambda: mock_grpc_client - ) - component.build() - - component._on_refresh_click(MagicMock()) - - mock_grpc_client.list_meetings.assert_called_once() diff --git a/tests/client/test_playback_controls.py b/tests/client/test_playback_controls.py deleted file mode 100644 index e99c948..0000000 --- a/tests/client/test_playback_controls.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Tests for PlaybackControlsComponent.""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -import flet as ft -import pytest - -from noteflow.client.components.playback_controls import PlaybackControlsComponent -from noteflow.infrastructure.audio import PlaybackState - -from .conftest import MockAppState, MockPlayback, MockPlaybackState - - -class TestPlaybackControlsBuild: - """Tests for PlaybackControlsComponent.build().""" - - def test_build_returns_flet_row(self, mock_app_state: MockAppState) -> None: - """build() should return ft.Row.""" - component = PlaybackControlsComponent(mock_app_state) - - result = component.build() - - assert isinstance(result, ft.Row) - - def test_build_contains_play_button(self, mock_app_state: MockAppState) -> None: - """build() should create play button.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._play_btn is not None - assert isinstance(component._play_btn, ft.IconButton) - - def test_build_contains_stop_button(self, mock_app_state: MockAppState) -> None: - """build() should create stop button.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._stop_btn is not None - assert isinstance(component._stop_btn, ft.IconButton) - - def test_build_contains_timeline_slider(self, mock_app_state: MockAppState) -> None: - """build() should create timeline slider.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._timeline_slider is not None - assert isinstance(component._timeline_slider, ft.Slider) - - def test_build_contains_position_labels(self, mock_app_state: MockAppState) -> None: - """build() should create position and duration labels.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._position_label is not None - assert isinstance(component._position_label, ft.Text) - assert component._duration_label is not None - assert isinstance(component._duration_label, ft.Text) - - def test_initial_buttons_disabled(self, mock_app_state: MockAppState) -> None: - """Buttons should be disabled initially.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._play_btn is not None - assert component._play_btn.disabled is True - assert component._stop_btn is not None - assert component._stop_btn.disabled is True - - def test_initial_slider_disabled(self, mock_app_state: MockAppState) -> None: - """Slider should be disabled initially.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._timeline_slider is not None - assert component._timeline_slider.disabled is True - - def test_initial_visibility_hidden(self, mock_app_state: MockAppState) -> None: - """Row should be hidden by default.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._row is not None - assert component._row.visible is False - - def test_initial_position_label_zero(self, mock_app_state: MockAppState) -> None: - """Position label should show 00:00.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._position_label is not None - assert component._position_label.value == "00:00" - - def test_initial_play_button_icon(self, mock_app_state: MockAppState) -> None: - """Play button should show play icon.""" - component = PlaybackControlsComponent(mock_app_state) - - component.build() - - assert component._play_btn is not None - assert component._play_btn.icon == ft.Icons.PLAY_ARROW - - -class TestPlaybackControlsVisibility: - """Tests for visibility control.""" - - def test_set_visible_true_shows_row(self, mock_app_state: MockAppState) -> None: - """set_visible(True) should make row visible.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.set_visible(True) - - assert component._row is not None - assert component._row.visible is True - - def test_set_visible_false_hides_row(self, mock_app_state: MockAppState) -> None: - """set_visible(False) should hide row.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - component.set_visible(True) - - component.set_visible(False) - - assert component._row is not None - assert component._row.visible is False - - -class TestPlaybackControlsLoadAudio: - """Tests for load_audio().""" - - def test_load_audio_calls_playback_play(self, mock_app_state: MockAppState) -> None: - """load_audio() should call playback.play().""" - mock_app_state.session_audio_buffer = [b"audio1", b"audio2"] - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.load_audio() - - assert mock_app_state.playback._play_called is True - - def test_load_audio_pauses_after_play(self, mock_app_state: MockAppState) -> None: - """load_audio() should pause playback after loading.""" - mock_app_state.session_audio_buffer = [b"audio1", b"audio2"] - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.load_audio() - - assert mock_app_state.playback._pause_called is True - - def test_load_audio_enables_buttons(self, mock_app_state: MockAppState) -> None: - """load_audio() should enable play and stop buttons.""" - mock_app_state.session_audio_buffer = [b"audio1", b"audio2"] - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.load_audio() - - assert component._play_btn is not None - assert component._play_btn.disabled is False - assert component._stop_btn is not None - assert component._stop_btn.disabled is False - - def test_load_audio_enables_slider(self, mock_app_state: MockAppState) -> None: - """load_audio() should enable timeline slider.""" - mock_app_state.session_audio_buffer = [b"audio1", b"audio2"] - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.load_audio() - - assert component._timeline_slider is not None - assert component._timeline_slider.disabled is False - - def test_load_audio_makes_visible(self, mock_app_state: MockAppState) -> None: - """load_audio() should make controls visible.""" - mock_app_state.session_audio_buffer = [b"audio1", b"audio2"] - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.load_audio() - - assert component._row is not None - assert component._row.visible is True - - def test_load_audio_resets_position(self, mock_app_state: MockAppState) -> None: - """load_audio() should reset playback position to 0.""" - mock_app_state.session_audio_buffer = [b"audio1", b"audio2"] - mock_app_state.playback_position = 50.0 - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.load_audio() - - assert mock_app_state.playback_position == 0.0 - - def test_load_audio_empty_buffer_logs_warning( - self, mock_app_state: MockAppState - ) -> None: - """load_audio() with empty buffer should not crash.""" - mock_app_state.session_audio_buffer = [] - component = PlaybackControlsComponent(mock_app_state) - component.build() - - # Should not raise - component.load_audio() - - -class TestPlaybackControlsPlayPause: - """Tests for play/pause button behavior.""" - - def test_play_click_from_stopped_calls_play( - self, mock_app_state: MockAppState - ) -> None: - """Clicking play when stopped should call playback.play().""" - mock_app_state.session_audio_buffer = [b"audio"] - mock_app_state.playback.state = MockPlaybackState.STOPPED - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._on_play_click(MagicMock()) - - assert mock_app_state.playback._play_called is True - - def test_play_click_updates_icon_to_pause( - self, mock_app_state: MockAppState - ) -> None: - """Clicking play should change icon to pause.""" - mock_app_state.session_audio_buffer = [b"audio"] - mock_app_state.playback.state = MockPlaybackState.STOPPED - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._on_play_click(MagicMock()) - - assert component._play_btn is not None - assert component._play_btn.icon == ft.Icons.PAUSE - - def test_pause_click_from_playing_calls_pause( - self, mock_app_state: MockAppState - ) -> None: - """Clicking pause when playing should call playback.pause().""" - # Use the real PlaybackState from infrastructure - mock_app_state.playback.state = PlaybackState.PLAYING - component = PlaybackControlsComponent(mock_app_state) - component.build() - component._active = True - - component._on_play_click(MagicMock()) - - assert mock_app_state.playback._pause_called is True - - def test_pause_click_updates_icon_to_play( - self, mock_app_state: MockAppState - ) -> None: - """Clicking pause should change icon to play.""" - mock_app_state.playback.state = PlaybackState.PLAYING - component = PlaybackControlsComponent(mock_app_state) - component.build() - component._active = True - - component._on_play_click(MagicMock()) - - assert component._play_btn is not None - assert component._play_btn.icon == ft.Icons.PLAY_ARROW - - def test_resume_from_paused_calls_resume( - self, mock_app_state: MockAppState - ) -> None: - """Clicking play when paused should call playback.resume().""" - mock_app_state.playback.state = PlaybackState.PAUSED - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._on_play_click(MagicMock()) - - assert mock_app_state.playback._resume_called is True - - -class TestPlaybackControlsStop: - """Tests for stop button behavior.""" - - def test_stop_click_calls_stop(self, mock_app_state: MockAppState) -> None: - """Clicking stop should call playback.stop().""" - mock_app_state.playback.state = MockPlaybackState.PLAYING - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._on_stop_click(MagicMock()) - - assert mock_app_state.playback._stop_called is True - - def test_stop_click_resets_position(self, mock_app_state: MockAppState) -> None: - """Clicking stop should reset playback position to 0.""" - mock_app_state.playback_position = 50.0 - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._on_stop_click(MagicMock()) - - assert mock_app_state.playback_position == 0.0 - - def test_stop_click_updates_icon_to_play( - self, mock_app_state: MockAppState - ) -> None: - """Clicking stop should change icon to play.""" - mock_app_state.playback.state = MockPlaybackState.PLAYING - component = PlaybackControlsComponent(mock_app_state) - component.build() - # Simulate playing state icon - assert component._play_btn is not None - component._play_btn.icon = ft.Icons.PAUSE - - component._on_stop_click(MagicMock()) - - assert component._play_btn is not None - assert component._play_btn.icon == ft.Icons.PLAY_ARROW - - -class TestPlaybackControlsSeek: - """Tests for seek functionality.""" - - def test_slider_change_calls_seek(self, mock_app_state: MockAppState) -> None: - """Changing slider should call playback.seek().""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - assert component._timeline_slider is not None - component._timeline_slider.value = 25.0 - - component._on_slider_change(MagicMock()) - - assert mock_app_state.playback._last_seek_position == 25.0 - - def test_seek_method_updates_position(self, mock_app_state: MockAppState) -> None: - """seek() should update playback position.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component.seek(30.0) - - assert mock_app_state.playback_position == 30.0 - - -class TestPlaybackControlsPositionUpdates: - """Tests for position update callbacks.""" - - def test_start_position_updates_registers_callback( - self, mock_app_state: MockAppState - ) -> None: - """_start_position_updates should register callback.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._start_position_updates() - - assert len(mock_app_state.playback._position_callbacks) == 1 - - def test_stop_position_updates_unregisters_callback( - self, mock_app_state: MockAppState - ) -> None: - """_stop_position_updates should unregister callback.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - component._start_position_updates() - - component._stop_position_updates() - - assert len(mock_app_state.playback._position_callbacks) == 0 - - def test_position_callback_updates_slider( - self, mock_app_state: MockAppState - ) -> None: - """Position callback should update slider value.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - assert component._timeline_slider is not None - component._timeline_slider.disabled = False - component._start_position_updates() - - mock_app_state.playback.simulate_position_update(15.0) - - assert component._timeline_slider is not None - assert component._timeline_slider.value == 15.0 - - def test_position_callback_updates_state( - self, mock_app_state: MockAppState - ) -> None: - """Position callback should update state.playback_position.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - component._start_position_updates() - - mock_app_state.playback.simulate_position_update(20.0) - - assert mock_app_state.playback_position == 20.0 - - def test_position_callback_invokes_external_callback( - self, mock_app_state: MockAppState - ) -> None: - """Position callback should invoke on_position_change.""" - callback = MagicMock() - component = PlaybackControlsComponent(mock_app_state, on_position_change=callback) - component.build() - component._start_position_updates() - - mock_app_state.playback.simulate_position_update(10.0) - - callback.assert_called_once_with(10.0) - - def test_inactive_component_ignores_position_updates( - self, mock_app_state: MockAppState - ) -> None: - """Inactive component should ignore position updates.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - component._active = False - mock_app_state.playback._position_callbacks.append(component._on_position_update) - - # Should not update state - initial_position = mock_app_state.playback_position - component._on_position_update(50.0) - - assert mock_app_state.playback_position == initial_position - - -class TestPlaybackControlsPlaybackFinished: - """Tests for playback completion handling.""" - - def test_on_playback_finished_resets_icon( - self, mock_app_state: MockAppState - ) -> None: - """_on_playback_finished should reset play button icon.""" - component = PlaybackControlsComponent(mock_app_state) - component.build() - assert component._play_btn is not None - component._play_btn.icon = ft.Icons.PAUSE - - component._on_playback_finished() - - assert component._play_btn is not None - assert component._play_btn.icon == ft.Icons.PLAY_ARROW - - def test_on_playback_finished_resets_position( - self, mock_app_state: MockAppState - ) -> None: - """_on_playback_finished should reset position to 0.""" - mock_app_state.playback_position = 60.0 - component = PlaybackControlsComponent(mock_app_state) - component.build() - - component._on_playback_finished() - - assert mock_app_state.playback_position == 0.0 diff --git a/tests/client/test_summary_panel.py b/tests/client/test_summary_panel.py deleted file mode 100644 index 05e313f..0000000 --- a/tests/client/test_summary_panel.py +++ /dev/null @@ -1,559 +0,0 @@ -"""Tests for SummaryPanelComponent.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from unittest.mock import Mock -from uuid import uuid4 - -import flet as ft -import pytest - -from noteflow.client.components.summary_panel import ( - PRIORITY_COLORS, - PRIORITY_LABELS, - SummaryPanelComponent, -) -from noteflow.domain.entities import ActionItem, KeyPoint, Summary -from noteflow.domain.value_objects import MeetingId - - -@dataclass -class MockAppState: - """Minimal mock AppState for testing.""" - - transcript_segments: list = field(default_factory=list) - current_meeting: Mock | None = None - current_summary: Summary | None = None - summary_loading: bool = False - summary_error: str | None = None - _page: Mock | None = None - - def request_update(self) -> None: - """No-op for tests.""" - - def run_on_ui_thread(self, callback) -> None: - """Execute callback immediately for tests.""" - callback() if callable(callback) else None - - -def _create_mock_state() -> MockAppState: - """Create mock AppState with meeting.""" - state = MockAppState() - state.current_meeting = Mock() - state.current_meeting.id = str(uuid4()) - return state - - -def _create_summary( - key_points: list[KeyPoint] | None = None, - action_items: list[ActionItem] | None = None, -) -> Summary: - """Create test Summary.""" - return Summary( - meeting_id=MeetingId(uuid4()), - executive_summary="Test executive summary.", - key_points=key_points or [], - action_items=action_items or [], - ) - - -class TestSummaryPanelBuild: - """Tests for SummaryPanelComponent.build().""" - - def test_build_returns_container(self) -> None: - """build() should return ft.Container.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - result = panel.build() - - assert isinstance(result, ft.Container) - - def test_build_initially_hidden(self) -> None: - """Panel should be hidden by default.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - container = panel.build() - - assert container.visible is False - - def test_build_creates_ui_elements(self) -> None: - """build() should create all UI elements.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - panel.build() - - assert panel._summary_text is not None - assert panel._key_points_list is not None - assert panel._action_items_list is not None - assert panel._generate_btn is not None - assert panel._loading_indicator is not None - assert panel._error_text is not None - - -class TestSummaryPanelVisibility: - """Tests for visibility control.""" - - def test_set_visible_shows_panel(self) -> None: - """set_visible(True) should show panel.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel.set_visible(True) - - assert panel._container is not None - assert panel._container.visible is True - - def test_set_visible_hides_panel(self) -> None: - """set_visible(False) should hide panel.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel.set_visible(True) - - panel.set_visible(False) - - assert panel._container is not None - assert panel._container.visible is False - - -class TestSummaryPanelEnabled: - """Tests for enabled state control.""" - - def test_set_enabled_enables_button(self) -> None: - """set_enabled(True) should enable generate button.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel.set_enabled(True) - - assert panel._generate_btn is not None - assert panel._generate_btn.disabled is False - - def test_set_enabled_disables_button(self) -> None: - """set_enabled(False) should disable generate button.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel.set_enabled(True) - - panel.set_enabled(False) - - assert panel._generate_btn is not None - assert panel._generate_btn.disabled is True - - -class TestSummaryPanelRender: - """Tests for rendering summary content.""" - - def test_render_summary_shows_executive_summary(self) -> None: - """_render_summary should display executive summary text.""" - state = _create_mock_state() - state.current_summary = _create_summary() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel._render_summary() - - assert panel._summary_text is not None - assert panel._summary_text.value == "Test executive summary." - - def test_render_summary_populates_key_points(self) -> None: - """_render_summary should populate key points list.""" - state = _create_mock_state() - state.current_summary = _create_summary( - key_points=[ - KeyPoint(text="Point 1", segment_ids=[0]), - KeyPoint(text="Point 2", segment_ids=[1]), - ] - ) - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel._render_summary() - - assert panel._key_points_list is not None - assert len(panel._key_points_list.controls) == 2 - - def test_render_summary_populates_action_items(self) -> None: - """_render_summary should populate action items list.""" - state = _create_mock_state() - state.current_summary = _create_summary( - action_items=[ - ActionItem(text="Action 1", segment_ids=[0], priority=1), - ActionItem(text="Action 2", segment_ids=[1], priority=2, assignee="Alice"), - ] - ) - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel._render_summary() - - assert panel._action_items_list is not None - assert len(panel._action_items_list.controls) == 2 - - -class TestCitationChips: - """Tests for citation chip functionality.""" - - def test_create_citation_chip_returns_container(self) -> None: - """_create_citation_chip should return Container.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - chip = panel._create_citation_chip(5) - - assert isinstance(chip, ft.Container) - - def test_citation_chip_has_correct_label(self) -> None: - """Citation chip should display [#N] format.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - chip = panel._create_citation_chip(42) - text = chip.content - - assert isinstance(text, ft.Text) - assert text.value == "[#42]" - - def test_citation_chip_click_calls_callback(self) -> None: - """Clicking citation chip should call on_citation_click.""" - clicked_ids: list[int] = [] - state = _create_mock_state() - panel = SummaryPanelComponent( - state, - get_service=lambda: None, - on_citation_click=lambda sid: clicked_ids.append(sid), - ) - - panel._handle_citation_click(7) - - assert clicked_ids == [7] - - def test_citation_click_no_callback_is_noop(self) -> None: - """Citation click with no callback should not raise.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None, on_citation_click=None) - - panel._handle_citation_click(5) # Should not raise - - -class TestPriorityBadge: - """Tests for priority badge functionality.""" - - @pytest.mark.parametrize( - ("priority", "expected_label"), - [ - (0, "—"), - (1, "Low"), - (2, "Med"), - (3, "High"), - ], - ) - def test_priority_badge_labels(self, priority: int, expected_label: str) -> None: - """Priority badge should show correct label.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - badge = panel._create_priority_badge(priority) - text = badge.content - - assert isinstance(text, ft.Text) - assert text.value == expected_label - - @pytest.mark.parametrize( - ("priority", "expected_color"), - [ - (0, ft.Colors.GREY_400), - (1, ft.Colors.BLUE_400), - (2, ft.Colors.ORANGE_400), - (3, ft.Colors.RED_400), - ], - ) - def test_priority_badge_colors(self, priority: int, expected_color: str) -> None: - """Priority badge should have correct background color.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - badge = panel._create_priority_badge(priority) - - assert badge.bgcolor == expected_color - - -class TestLoadingAndError: - """Tests for loading and error states.""" - - def test_update_loading_state_shows_indicator(self) -> None: - """Loading indicator should be visible when loading.""" - state = _create_mock_state() - state.summary_loading = True - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel._update_loading_state() - - assert panel._loading_indicator is not None - assert panel._generate_btn is not None - assert panel._loading_indicator.visible is True - assert panel._generate_btn.disabled is True - - def test_update_loading_state_hides_indicator(self) -> None: - """Loading indicator should be hidden when not loading.""" - state = _create_mock_state() - state.summary_loading = False - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - assert panel._loading_indicator is not None - panel._loading_indicator.visible = True - - panel._update_loading_state() - - assert not panel._loading_indicator.visible - - def test_show_error_displays_message(self) -> None: - """_show_error should display error message.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - panel._show_error("Test error message") - - assert panel._error_text is not None - assert panel._error_text.value == "Test error message" - assert panel._error_text.visible is True - - def test_clear_error_hides_message(self) -> None: - """_clear_error should hide error message.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel._show_error("Error") - - panel._clear_error() - - assert panel._error_text is not None - assert panel._error_text.value == "" - assert panel._error_text.visible is False - - -class TestPriorityConstants: - """Tests for priority constant values.""" - - def test_priority_colors_has_all_levels(self) -> None: - """PRIORITY_COLORS should have entries for all priority levels.""" - assert 0 in PRIORITY_COLORS - assert 1 in PRIORITY_COLORS - assert 2 in PRIORITY_COLORS - assert 3 in PRIORITY_COLORS - - def test_priority_labels_has_all_levels(self) -> None: - """PRIORITY_LABELS should have entries for all priority levels.""" - assert 0 in PRIORITY_LABELS - assert 1 in PRIORITY_LABELS - assert 2 in PRIORITY_LABELS - assert 3 in PRIORITY_LABELS - - -class TestUncitedDraftsToggle: - """Tests for uncited drafts toggle functionality.""" - - def test_build_creates_toggle_ui(self) -> None: - """build() should create uncited toggle and count text.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - panel.build() - - assert panel._uncited_toggle is not None - assert panel._uncited_count_text is not None - - def test_toggle_initially_hidden(self) -> None: - """Uncited toggle should be hidden by default.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - - assert panel._uncited_toggle is not None - assert panel._uncited_toggle.visible is False - - def test_calculate_uncited_counts_with_no_summaries(self) -> None: - """Uncited counts should be zero when no summaries.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - panel._calculate_uncited_counts() - - assert panel._uncited_key_points == 0 - assert panel._uncited_action_items == 0 - - def test_calculate_uncited_counts_with_filtered_items(self) -> None: - """Uncited counts should reflect difference between original and filtered.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - - # Original has 3 key points - panel._original_summary = _create_summary( - key_points=[ - KeyPoint(text="Point 1", segment_ids=[0]), - KeyPoint(text="Point 2", segment_ids=[1]), - KeyPoint(text="Point 3", segment_ids=[]), # uncited - ], - action_items=[ - ActionItem(text="Action 1", segment_ids=[0]), - ActionItem(text="Action 2", segment_ids=[]), # uncited - ], - ) - # Filtered has 2 key points (1 filtered out) - panel._filtered_summary = _create_summary( - key_points=[ - KeyPoint(text="Point 1", segment_ids=[0]), - KeyPoint(text="Point 2", segment_ids=[1]), - ], - action_items=[ - ActionItem(text="Action 1", segment_ids=[0]), - ], - ) - - panel._calculate_uncited_counts() - - assert panel._uncited_key_points == 1 - assert panel._uncited_action_items == 1 - - def test_has_uncited_items_true_when_filtered(self) -> None: - """_has_uncited_items should return True when items filtered.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel._uncited_key_points = 2 - panel._uncited_action_items = 0 - - assert panel._has_uncited_items() is True - - def test_has_uncited_items_false_when_none_filtered(self) -> None: - """_has_uncited_items should return False when nothing filtered.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel._uncited_key_points = 0 - panel._uncited_action_items = 0 - - assert panel._has_uncited_items() is False - - def test_update_uncited_ui_shows_toggle_when_uncited(self) -> None: - """Toggle should be visible when uncited items exist.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel._uncited_key_points = 2 - panel._uncited_action_items = 1 - - panel._update_uncited_ui() - - assert panel._uncited_toggle is not None - assert panel._uncited_toggle.visible is True - - def test_update_uncited_ui_hides_toggle_when_no_uncited(self) -> None: - """Toggle should be hidden when no uncited items.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel._uncited_key_points = 0 - panel._uncited_action_items = 0 - - panel._update_uncited_ui() - - assert panel._uncited_toggle is not None - assert panel._uncited_toggle.visible is False - - def test_update_uncited_ui_shows_count_text(self) -> None: - """Count text should show total uncited when toggle is off.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel._uncited_key_points = 2 - panel._uncited_action_items = 3 - panel._show_uncited = False - - panel._update_uncited_ui() - - assert panel._uncited_count_text is not None - assert panel._uncited_count_text.visible is True - assert panel._uncited_count_text.value == "(5 hidden)" - - def test_update_uncited_ui_hides_count_when_showing_uncited(self) -> None: - """Count text should be hidden when showing uncited items.""" - state = _create_mock_state() - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel._uncited_key_points = 2 - panel._uncited_action_items = 0 - panel._show_uncited = True - - panel._update_uncited_ui() - - assert panel._uncited_count_text is not None - assert panel._uncited_count_text.visible is False - - def test_get_display_summary_returns_original_when_toggled(self) -> None: - """_get_display_summary should return original when showing uncited.""" - state = _create_mock_state() - original = _create_summary(key_points=[KeyPoint(text="Original", segment_ids=[])]) - filtered = _create_summary(key_points=[]) - state.current_summary = filtered - - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel._original_summary = original - panel._filtered_summary = filtered - panel._show_uncited = True - - result = panel._get_display_summary() - - assert result is original - - def test_get_display_summary_returns_current_when_not_toggled(self) -> None: - """_get_display_summary should return current_summary when toggle off.""" - state = _create_mock_state() - original = _create_summary(key_points=[KeyPoint(text="Original", segment_ids=[])]) - filtered = _create_summary(key_points=[]) - state.current_summary = filtered - - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel._original_summary = original - panel._filtered_summary = filtered - panel._show_uncited = False - - result = panel._get_display_summary() - - assert result is filtered - - def test_render_summary_switches_on_toggle(self) -> None: - """Rendering should switch content based on toggle state.""" - state = _create_mock_state() - original = _create_summary( - key_points=[ - KeyPoint(text="Point 1", segment_ids=[0]), - KeyPoint(text="Uncited", segment_ids=[]), - ] - ) - filtered = _create_summary(key_points=[KeyPoint(text="Point 1", segment_ids=[0])]) - state.current_summary = filtered - - panel = SummaryPanelComponent(state, get_service=lambda: None) - panel.build() - panel._original_summary = original - panel._filtered_summary = filtered - panel._uncited_key_points = 1 - - # First render with toggle off - panel._show_uncited = False - panel._render_summary() - assert panel._key_points_list is not None - assert len(panel._key_points_list.controls) == 1 - - # Toggle on and re-render - panel._show_uncited = True - panel._render_summary() - assert len(panel._key_points_list.controls) == 2 diff --git a/tests/client/test_transcript_component.py b/tests/client/test_transcript_component.py deleted file mode 100644 index 6b80fbc..0000000 --- a/tests/client/test_transcript_component.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Tests for TranscriptComponent including partial rendering.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING -from unittest.mock import MagicMock - -import flet as ft - -if TYPE_CHECKING: - from collections.abc import Callable - -from noteflow.client.components.transcript import TranscriptComponent - - -@dataclass -class MockTranscriptSegment: - """Mock TranscriptSegment for testing.""" - - text: str - start_time: float - end_time: float - is_final: bool = True - speaker_id: str = "" - speaker_confidence: float = 0.0 - - -@dataclass -class MockServerInfo: - """Mock ServerInfo for testing.""" - - version: str = "1.0.0" - asr_model: str = "base" - asr_ready: bool = True - active_meetings: int = 0 - - -@dataclass -class MockAppState: - """Minimal mock AppState for testing transcript component.""" - - transcript_segments: list[MockTranscriptSegment] = field(default_factory=list) - current_partial_text: str = "" - _page: MagicMock | None = None - - def request_update(self) -> None: - """No-op for tests.""" - - def run_on_ui_thread(self, callback: Callable[[], None]) -> None: - """Execute callback immediately for tests.""" - callback() - - def clear_transcript(self) -> None: - """Clear transcript segments and partial text.""" - self.transcript_segments.clear() - self.current_partial_text = "" - - -class TestTranscriptComponentBuild: - """Tests for TranscriptComponent.build().""" - - def test_build_returns_column(self) -> None: - """build() should return ft.Column.""" - state = MockAppState() - component = TranscriptComponent(state) - - result = component.build() - - assert isinstance(result, ft.Column) - - def test_build_creates_search_field(self) -> None: - """build() should create search field.""" - state = MockAppState() - component = TranscriptComponent(state) - - component.build() - - assert component._search_field is not None - assert isinstance(component._search_field, ft.TextField) - - def test_build_creates_list_view(self) -> None: - """build() should create ListView.""" - state = MockAppState() - component = TranscriptComponent(state) - - component.build() - - assert component._list_view is not None - assert isinstance(component._list_view, ft.ListView) - - -class TestTranscriptPartialRendering: - """Tests for partial transcript rendering.""" - - def test_add_partial_segment_updates_state(self) -> None: - """Adding partial segment should update state partial text.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - partial = MockTranscriptSegment( - text="Hello, I am speaking...", - start_time=0.0, - end_time=1.0, - is_final=False, - ) - component.add_segment(partial) - - assert state.current_partial_text == "Hello, I am speaking..." - - def test_add_partial_creates_partial_row(self) -> None: - """Adding partial segment should create partial row in ListView.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - partial = MockTranscriptSegment( - text="Speaking now...", - start_time=0.0, - end_time=1.0, - is_final=False, - ) - component.add_segment(partial) - - assert component._partial_row is not None - assert component._list_view is not None - assert component._partial_row in component._list_view.controls - - def test_partial_row_has_live_indicator(self) -> None: - """Partial row should contain [LIVE] indicator.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - partial = MockTranscriptSegment( - text="Testing...", - start_time=0.0, - end_time=1.0, - is_final=False, - ) - component.add_segment(partial) - - # Check that partial row content contains LIVE indicator - assert component._partial_row is not None - partial_content = component._partial_row.content - assert isinstance(partial_content, ft.Row) - # First element should be the LIVE text - live_text = partial_content.controls[0] - assert isinstance(live_text, ft.Text) - assert live_text.value is not None - assert "[LIVE]" in live_text.value - - def test_partial_row_has_italic_styling(self) -> None: - """Partial row text should be italicized.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - partial = MockTranscriptSegment( - text="Testing...", - start_time=0.0, - end_time=1.0, - is_final=False, - ) - component.add_segment(partial) - - assert component._partial_row is not None - partial_content = component._partial_row.content - assert isinstance(partial_content, ft.Row) - text_element = partial_content.controls[1] - assert isinstance(text_element, ft.Text) - assert text_element.italic is True - - def test_partial_row_updated_on_new_partial(self) -> None: - """Subsequent partials should update existing row, not create new.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - # First partial - component.add_segment( - MockTranscriptSegment(text="First", start_time=0.0, end_time=1.0, is_final=False) - ) - first_row = component._partial_row - assert component._list_view is not None - initial_count = len(component._list_view.controls) - - # Second partial - component.add_segment( - MockTranscriptSegment(text="Second", start_time=1.0, end_time=2.0, is_final=False) - ) - - # Should update same row, not add new - assert component._partial_row is first_row - assert component._list_view is not None - assert len(component._list_view.controls) == initial_count - - -class TestTranscriptFinalSegment: - """Tests for final segment handling.""" - - def test_add_final_segment_clears_partial_text(self) -> None: - """Adding final segment should clear partial text state.""" - state = MockAppState() - state.current_partial_text = "Partial text..." - component = TranscriptComponent(state) - component.build() - - final = MockTranscriptSegment( - text="Final transcript.", - start_time=0.0, - end_time=2.0, - is_final=True, - ) - component.add_segment(final) - - assert not state.current_partial_text - - def test_add_final_removes_partial_row(self) -> None: - """Adding final segment should remove partial row.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - # Add partial first - partial = MockTranscriptSegment( - text="Speaking...", - start_time=0.0, - end_time=1.0, - is_final=False, - ) - component.add_segment(partial) - assert component._partial_row is not None - - # Add final - final = MockTranscriptSegment( - text="Final text.", - start_time=0.0, - end_time=2.0, - is_final=True, - ) - component.add_segment(final) - - # Partial row should be removed - assert component._partial_row is None - - def test_add_final_appends_to_segments(self) -> None: - """Adding final segment should append to state transcript_segments.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - final = MockTranscriptSegment( - text="Final text.", - start_time=0.0, - end_time=2.0, - is_final=True, - ) - component.add_segment(final) - - assert len(state.transcript_segments) == 1 - assert state.transcript_segments[0].text == "Final text." - - -class TestTranscriptClear: - """Tests for transcript clearing.""" - - def test_clear_removes_partial_row(self) -> None: - """clear() should remove partial row.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - # Add partial - partial = MockTranscriptSegment( - text="Partial...", - start_time=0.0, - end_time=1.0, - is_final=False, - ) - component.add_segment(partial) - - component.clear() - - assert component._partial_row is None - - def test_clear_empties_list_view(self) -> None: - """clear() should empty ListView controls.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - # Add some segments - component.add_segment( - MockTranscriptSegment(text="First", start_time=0.0, end_time=1.0, is_final=True) - ) - component.add_segment( - MockTranscriptSegment(text="Second", start_time=1.0, end_time=2.0, is_final=True) - ) - - component.clear() - - assert component._list_view is not None - assert len(component._list_view.controls) == 0 - - def test_clear_clears_search_field(self) -> None: - """clear() should clear search field.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - assert component._search_field is not None - component._search_field.value = "test query" - - component.clear() - - assert component._search_field is not None - assert not component._search_field.value - - -class TestTranscriptSearch: - """Tests for transcript search functionality.""" - - def test_search_filters_segments(self) -> None: - """Search should filter visible segments via visibility toggle.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - # Add segments to state - state.transcript_segments = [ - MockTranscriptSegment(text="Hello world", start_time=0.0, end_time=1.0), - MockTranscriptSegment(text="Goodbye world", start_time=1.0, end_time=2.0), - MockTranscriptSegment(text="Something else", start_time=2.0, end_time=3.0), - ] - - # Simulate search - component._search_query = "world" - component._rerender_all_segments() - - # All rows exist, but only matching ones are visible - assert len(component._segment_rows) == 3 - visible_count = sum(row.visible for row in component._segment_rows) - assert visible_count == 2 - - def test_search_is_case_insensitive(self) -> None: - """Search should be case-insensitive.""" - state = MockAppState() - component = TranscriptComponent(state) - component.build() - - state.transcript_segments = [ - MockTranscriptSegment(text="Hello WORLD", start_time=0.0, end_time=1.0), - MockTranscriptSegment(text="something else", start_time=1.0, end_time=2.0), - ] - - component._search_query = "world" - component._rerender_all_segments() - - # All rows exist, but only matching ones are visible - assert len(component._segment_rows) == 2 - visible_count = sum(row.visible for row in component._segment_rows) - assert visible_count == 1 - - -class TestTranscriptSegmentClick: - """Tests for segment click handling.""" - - def test_click_callback_receives_segment_index(self) -> None: - """Clicking segment should call callback with segment index.""" - clicked_indices: list[int] = [] - state = MockAppState() - component = TranscriptComponent( - state, - on_segment_click=lambda idx: clicked_indices.append(idx), - ) - component.build() - - component._handle_click(5) - - assert clicked_indices == [5] - - def test_click_without_callback_is_noop(self) -> None: - """Click without callback should not raise.""" - state = MockAppState() - component = TranscriptComponent(state, on_segment_click=None) - component.build() - - component._handle_click(3) # Should not raise diff --git a/tests/client/test_vu_meter.py b/tests/client/test_vu_meter.py deleted file mode 100644 index 7de0e2b..0000000 --- a/tests/client/test_vu_meter.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Tests for VuMeterComponent.""" - -from __future__ import annotations - -from unittest.mock import patch - -import flet as ft -import numpy as np -import pytest -from numpy.typing import NDArray - -from noteflow.client.components.vu_meter import VU_UPDATE_INTERVAL, VuMeterComponent - -from .conftest import MockAppState, MockRmsLevelProvider - - -class TestVuMeterBuild: - """Tests for VuMeterComponent.build().""" - - def test_build_returns_flet_row(self, mock_app_state: MockAppState) -> None: - """build() should return ft.Row.""" - component = VuMeterComponent(mock_app_state) - - result = component.build() - - assert isinstance(result, ft.Row) - - def test_build_contains_progress_bar(self, mock_app_state: MockAppState) -> None: - """build() should create progress bar.""" - component = VuMeterComponent(mock_app_state) - - component.build() - - assert component._progress_bar is not None - assert isinstance(component._progress_bar, ft.ProgressBar) - - def test_build_contains_label(self, mock_app_state: MockAppState) -> None: - """build() should create dB label.""" - component = VuMeterComponent(mock_app_state) - - component.build() - - assert component._label is not None - assert isinstance(component._label, ft.Text) - - def test_initial_progress_bar_value_zero(self, mock_app_state: MockAppState) -> None: - """Progress bar should start at 0.""" - component = VuMeterComponent(mock_app_state) - - component.build() - - assert component._progress_bar is not None - assert component._progress_bar.value == 0 - - def test_initial_label_shows_minus_60_db(self, mock_app_state: MockAppState) -> None: - """Label should start at -60 dB.""" - component = VuMeterComponent(mock_app_state) - - component.build() - - assert component._label is not None - assert component._label.value == "-60 dB" - - def test_progress_bar_has_green_color(self, mock_app_state: MockAppState) -> None: - """Progress bar should start with green color.""" - component = VuMeterComponent(mock_app_state) - - component.build() - - assert component._progress_bar is not None - assert component._progress_bar.color == ft.Colors.GREEN - - -class TestVuMeterAudioFrames: - """Tests for VuMeterComponent.on_audio_frames().""" - - def test_on_audio_frames_updates_level( - self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32] - ) -> None: - """on_audio_frames() should update state.current_db_level.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert mock_app_state.current_db_level == -30.0 - - def test_on_audio_frames_throttled( - self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32] - ) -> None: - """on_audio_frames() should throttle updates to VU_UPDATE_INTERVAL.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0) - component = VuMeterComponent(mock_app_state) - component.build() - - # First call should update - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - first_level = mock_app_state.current_db_level - - # Second call within throttle interval should NOT update - mock_app_state.level_provider.set_db(-10.0) - with patch("time.time", return_value=1.0 + VU_UPDATE_INTERVAL / 2): - component.on_audio_frames(sample_audio_frames) - - # Level should remain unchanged (throttled) - assert mock_app_state.current_db_level == first_level - - def test_on_audio_frames_updates_after_interval( - self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32] - ) -> None: - """on_audio_frames() should update after throttle interval passes.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0) - component = VuMeterComponent(mock_app_state) - component.build() - - # First call - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - # Second call after interval - mock_app_state.level_provider.set_db(-10.0) - with patch("time.time", return_value=1.0 + VU_UPDATE_INTERVAL + 0.01): - component.on_audio_frames(sample_audio_frames) - - assert mock_app_state.current_db_level == -10.0 - - def test_on_audio_frames_updates_progress_bar( - self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32] - ) -> None: - """on_audio_frames() should update progress bar value.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=-30.0) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - # -30 dB normalized: (-30 + 60) / 60 = 0.5 - assert component._progress_bar.value == 0.5 - - -class TestVuMeterColorCoding: - """Tests for VU meter color coding based on dB level.""" - - @pytest.mark.parametrize( - ("db_level", "expected_color"), - [ - (-5.0, ft.Colors.RED), - (-3.0, ft.Colors.RED), - (0.0, ft.Colors.RED), - ], - ) - def test_color_red_above_negative_6_db( - self, - mock_app_state: MockAppState, - sample_audio_frames: NDArray[np.float32], - db_level: float, - expected_color: str, - ) -> None: - """dB > -6 should show RED.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - assert component._progress_bar.color == expected_color - - @pytest.mark.parametrize( - ("db_level", "expected_color"), - [ - (-6.0, ft.Colors.YELLOW), - (-10.0, ft.Colors.YELLOW), - (-15.0, ft.Colors.YELLOW), - (-19.0, ft.Colors.YELLOW), - ], - ) - def test_color_yellow_between_negative_20_and_negative_6( - self, - mock_app_state: MockAppState, - sample_audio_frames: NDArray[np.float32], - db_level: float, - expected_color: str, - ) -> None: - """-20 < dB <= -6 should show YELLOW.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - assert component._progress_bar.color == expected_color - - @pytest.mark.parametrize( - ("db_level", "expected_color"), - [ - (-20.0, ft.Colors.GREEN), - (-30.0, ft.Colors.GREEN), - (-40.0, ft.Colors.GREEN), - (-60.0, ft.Colors.GREEN), - ], - ) - def test_color_green_at_or_below_negative_20_db( - self, - mock_app_state: MockAppState, - sample_audio_frames: NDArray[np.float32], - db_level: float, - expected_color: str, - ) -> None: - """dB <= -20 should show GREEN.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - assert component._progress_bar.color == expected_color - - -class TestVuMeterNormalization: - """Tests for dB to progress bar value normalization.""" - - @pytest.mark.parametrize( - ("db_level", "expected_value"), - [ - (-60.0, 0.0), - (-30.0, 0.5), - (0.0, 1.0), - ], - ) - def test_normalize_db_to_progress_value( - self, - mock_app_state: MockAppState, - sample_audio_frames: NDArray[np.float32], - db_level: float, - expected_value: float, - ) -> None: - """dB should normalize to 0-1 range.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - assert component._progress_bar.value == pytest.approx(expected_value, abs=0.01) - - def test_normalize_clamps_below_minus_60( - self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32] - ) -> None: - """dB below -60 should clamp to 0.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=-80.0) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - assert component._progress_bar.value == 0.0 - - def test_normalize_clamps_above_zero( - self, mock_app_state: MockAppState, sample_audio_frames: NDArray[np.float32] - ) -> None: - """dB above 0 should clamp to 1.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=10.0) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._progress_bar is not None - assert component._progress_bar.value == 1.0 - - -class TestVuMeterLabelUpdate: - """Tests for dB label updates.""" - - @pytest.mark.parametrize( - ("db_level", "expected_label"), - [ - (-60.0, "-60 dB"), - (-30.0, "-30 dB"), - (-10.0, "-10 dB"), - (0.0, "0 dB"), - ], - ) - def test_label_shows_db_value( - self, - mock_app_state: MockAppState, - sample_audio_frames: NDArray[np.float32], - db_level: float, - expected_label: str, - ) -> None: - """Label should show formatted dB value.""" - mock_app_state.level_provider = MockRmsLevelProvider(db_value=db_level) - component = VuMeterComponent(mock_app_state) - component.build() - - with patch("time.time", return_value=1.0): - component._last_update_time = 0.0 - component.on_audio_frames(sample_audio_frames) - - assert component._label is not None - assert component._label.value == expected_label