Spaces:
Paused
Paused
Commit
·
3b08f11
1
Parent(s):
5f0db1e
MVP UI simplification: single room, 4 tabs
Browse files- Remove Project Info tab and ProjectFormData
- Change from multi-room to single room (session.room)
- Move facility_classification and construction_era to RoomFormData
- Simplify from 5 tabs to 4: Room, Images, Observations, Results
- Delete all test files (testing on HuggingFace due to GPU/ChromaDB)
- Update pipeline files to use session.room
- Update samples and documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- CLAUDE.md +12 -10
- app.py +181 -259
- pipeline/calculations.py +11 -15
- pipeline/generator.py +25 -30
- pipeline/main.py +9 -14
- tests/__init__.py +0 -0
- tests/conftest.py +0 -68
- tests/test_e2e_forms.py +0 -307
- tests/test_e2e_samples.py +0 -148
- tests/test_e2e_workflow.py +0 -150
- tests/test_pdf_generator.py +0 -246
- tests/test_pipeline.py +0 -525
- tests/test_rag.py +0 -536
- tests/test_samples.py +0 -296
- tests/test_schemas.py +0 -459
- tests/test_tabs.py +0 -401
- tests/test_ui_state.py +0 -360
- ui/__init__.py +0 -4
- ui/components.py +6 -30
- ui/samples.py +19 -73
- ui/state.py +61 -84
- ui/tabs/__init__.py +2 -4
- ui/tabs/images.py +24 -48
- ui/tabs/project.py +0 -287
- ui/tabs/results.py +20 -28
- ui/tabs/room.py +275 -0
- ui/tabs/rooms.py +0 -344
CLAUDE.md
CHANGED
|
@@ -33,20 +33,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
| 33 |
|
| 34 |
## UI Components (Gradio 6.x)
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
The frontend uses optimized input components:
|
| 37 |
|
| 38 |
| Field | Component | Notes |
|
| 39 |
|-------|-----------|-------|
|
| 40 |
-
|
|
| 41 |
-
|
|
| 42 |
-
| ZIP Code | `gr.Textbox` + blur validation | Real-time format validation |
|
| 43 |
-
| Credentials | `gr.Dropdown(multiselect=True)` | CIH, CSP, PE, etc. |
|
| 44 |
-
| Floor | `gr.Dropdown` | Basement through Roof |
|
| 45 |
| Ceiling Height | `gr.Dropdown` + custom option | 8-20 ft presets |
|
| 46 |
-
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
**Keyboard Shortcuts:**
|
| 49 |
-
- `Ctrl+1` through `Ctrl+
|
| 50 |
|
| 51 |
## Development Commands
|
| 52 |
|
|
@@ -63,8 +65,8 @@ python app.py
|
|
| 63 |
# Recommended tooling (install as dev dependencies)
|
| 64 |
ruff check . # Linting
|
| 65 |
ruff format . # Formatting
|
| 66 |
-
pytest tests/ -v # Testing
|
| 67 |
mypy . # Type checking
|
|
|
|
| 68 |
```
|
| 69 |
|
| 70 |
## Architecture
|
|
@@ -85,10 +87,10 @@ mypy . # Type checking
|
|
| 85 |
├── rag/ # Chunking, vectorstore, retrieval
|
| 86 |
├── schemas/ # Pydantic input/output models
|
| 87 |
├── pipeline/ # Main processing logic
|
| 88 |
-
├── ui/ # Gradio UI components
|
| 89 |
├── RAG-KB/ # Knowledge base source files
|
| 90 |
├── chroma_db/ # ChromaDB persistence (generated)
|
| 91 |
-
└──
|
| 92 |
```
|
| 93 |
|
| 94 |
## Domain Knowledge
|
|
|
|
| 33 |
|
| 34 |
## UI Components (Gradio 6.x)
|
| 35 |
|
| 36 |
+
**MVP Simplification:** 4 tabs (Room Assessment, Images, Observations, Generate Results).
|
| 37 |
+
Single-room workflow - no project-level or multi-room support.
|
| 38 |
+
|
| 39 |
The frontend uses optimized input components:
|
| 40 |
|
| 41 |
| Field | Component | Notes |
|
| 42 |
|-------|-----------|-------|
|
| 43 |
+
| Room Name | `gr.Textbox` | Required field |
|
| 44 |
+
| Dimensions | `gr.Number` | Length, Width in feet |
|
|
|
|
|
|
|
|
|
|
| 45 |
| Ceiling Height | `gr.Dropdown` + custom option | 8-20 ft presets |
|
| 46 |
+
| Facility Classification | `gr.Radio` | operational, non-operational, public-childcare |
|
| 47 |
+
| Construction Era | `gr.Radio` | pre-1980, 1980-2000, post-2000 |
|
| 48 |
+
| Image Upload | `gr.Files(file_count="multiple")` | Batch upload, auto-assigned to room |
|
| 49 |
|
| 50 |
**Keyboard Shortcuts:**
|
| 51 |
+
- `Ctrl+1` through `Ctrl+4`: Navigate between tabs
|
| 52 |
|
| 53 |
## Development Commands
|
| 54 |
|
|
|
|
| 65 |
# Recommended tooling (install as dev dependencies)
|
| 66 |
ruff check . # Linting
|
| 67 |
ruff format . # Formatting
|
|
|
|
| 68 |
mypy . # Type checking
|
| 69 |
+
# Note: Tests removed - testing occurs on HuggingFace due to GPU/ChromaDB requirements
|
| 70 |
```
|
| 71 |
|
| 72 |
## Architecture
|
|
|
|
| 87 |
├── rag/ # Chunking, vectorstore, retrieval
|
| 88 |
├── schemas/ # Pydantic input/output models
|
| 89 |
├── pipeline/ # Main processing logic
|
| 90 |
+
├── ui/ # Gradio UI components (4 tabs: room, images, observations, results)
|
| 91 |
├── RAG-KB/ # Knowledge base source files
|
| 92 |
├── chroma_db/ # ChromaDB persistence (generated)
|
| 93 |
+
└── sample_images/ # Sample fire damage images for testing
|
| 94 |
```
|
| 95 |
|
| 96 |
## Domain Knowledge
|
app.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""FDAM AI Pipeline - Fire Damage Assessment Methodology v4.0.1
|
| 2 |
|
| 3 |
Main Gradio application entry point with session state and tab validation.
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import gradio as gr
|
|
@@ -17,18 +18,18 @@ logger = logging.getLogger(__name__)
|
|
| 17 |
from models.loader import get_models
|
| 18 |
from ui.state import SessionState, create_new_session, session_to_json, session_from_json
|
| 19 |
from ui.storage import get_head_html
|
| 20 |
-
from ui.tabs import
|
| 21 |
from ui import samples
|
| 22 |
|
| 23 |
|
| 24 |
-
# Keyboard shortcuts JavaScript (Ctrl+1-
|
| 25 |
KEYBOARD_JS = """
|
| 26 |
<script>
|
| 27 |
document.addEventListener('keydown', (e) => {
|
| 28 |
-
if (e.ctrlKey && e.key >= '1' && e.key <= '
|
| 29 |
e.preventDefault();
|
| 30 |
const tabIds = [
|
| 31 |
-
'tab-
|
| 32 |
'tab-observations-button', 'tab-results-button'
|
| 33 |
];
|
| 34 |
const tabIndex = parseInt(e.key) - 1;
|
|
@@ -73,7 +74,7 @@ def create_app() -> gr.Blocks:
|
|
| 73 |
# FDAM AI Pipeline
|
| 74 |
## Fire Damage Assessment Methodology v4.0.1
|
| 75 |
|
| 76 |
-
Upload images and
|
| 77 |
Cleaning Specification / Scope of Work.
|
| 78 |
"""
|
| 79 |
)
|
|
@@ -105,30 +106,25 @@ def create_app() -> gr.Blocks:
|
|
| 105 |
# Tab navigation (elem_id for stable JS selectors - Gradio appends "-button" for tab buttons)
|
| 106 |
# Store Tab references for individual select event handlers
|
| 107 |
with gr.Tabs() as tabs:
|
| 108 |
-
# Tab 1:
|
| 109 |
-
|
| 110 |
-
with
|
| 111 |
-
tab1 =
|
| 112 |
-
|
| 113 |
-
# Tab 2:
|
| 114 |
-
|
| 115 |
-
with tab_rooms:
|
| 116 |
-
tab2 = rooms.create_tab()
|
| 117 |
-
|
| 118 |
-
# Tab 3: Images
|
| 119 |
-
tab_images = gr.Tab("3. Images", id=2, elem_id="tab-images")
|
| 120 |
with tab_images:
|
| 121 |
-
|
| 122 |
|
| 123 |
-
# Tab
|
| 124 |
-
tab_observations = gr.Tab("
|
| 125 |
with tab_observations:
|
| 126 |
-
|
| 127 |
|
| 128 |
-
# Tab
|
| 129 |
-
tab_results = gr.Tab("
|
| 130 |
with tab_results:
|
| 131 |
-
|
| 132 |
|
| 133 |
# --- Event Handlers ---
|
| 134 |
|
|
@@ -139,7 +135,7 @@ def create_app() -> gr.Blocks:
|
|
| 139 |
# Empty selection, do nothing
|
| 140 |
return (
|
| 141 |
current_session, # session_state unchanged
|
| 142 |
-
*
|
| 143 |
gr.update(), # tabs unchanged
|
| 144 |
"", # clear status
|
| 145 |
"", # reset dropdown
|
|
@@ -150,7 +146,7 @@ def create_app() -> gr.Blocks:
|
|
| 150 |
if not new_session:
|
| 151 |
return (
|
| 152 |
current_session,
|
| 153 |
-
*
|
| 154 |
gr.update(),
|
| 155 |
'<span style="color: #c62828;">Error: Sample not found</span>',
|
| 156 |
"",
|
|
@@ -161,11 +157,11 @@ def create_app() -> gr.Blocks:
|
|
| 161 |
name = scenario.name if scenario else scenario_id
|
| 162 |
|
| 163 |
# Load form values from new session
|
| 164 |
-
form_values =
|
| 165 |
|
| 166 |
return (
|
| 167 |
new_session, # updated session_state
|
| 168 |
-
*form_values, #
|
| 169 |
gr.update(selected=0), # switch to Tab 1 (Gradio 6.x syntax)
|
| 170 |
f'<span style="color: #2e7d32;">Loaded sample: {name}</span>',
|
| 171 |
"", # reset dropdown to empty
|
|
@@ -176,42 +172,82 @@ def create_app() -> gr.Blocks:
|
|
| 176 |
inputs=[sample_dropdown, session_state],
|
| 177 |
outputs=[
|
| 178 |
session_state,
|
| 179 |
-
tab1["
|
| 180 |
-
tab1["
|
| 181 |
-
tab1["
|
| 182 |
-
tab1["
|
| 183 |
-
tab1["
|
| 184 |
-
tab1["
|
| 185 |
-
tab1["
|
| 186 |
-
tab1["assessment_date"],
|
| 187 |
tab1["facility_classification"],
|
| 188 |
tab1["construction_era"],
|
| 189 |
-
tab1["assessor_name"],
|
| 190 |
-
tab1["assessor_credentials"],
|
| 191 |
tabs,
|
| 192 |
sample_status,
|
| 193 |
sample_dropdown,
|
| 194 |
],
|
| 195 |
)
|
| 196 |
|
| 197 |
-
# Tab 1:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
tab1["validate_btn"].click(
|
| 199 |
-
fn=
|
| 200 |
-
inputs=[
|
| 201 |
-
session_state,
|
| 202 |
-
tab1["project_name"],
|
| 203 |
-
tab1["address"],
|
| 204 |
-
tab1["city"],
|
| 205 |
-
tab1["state"],
|
| 206 |
-
tab1["zip_code"],
|
| 207 |
-
tab1["client_name"],
|
| 208 |
-
tab1["fire_date"],
|
| 209 |
-
tab1["assessment_date"],
|
| 210 |
-
tab1["facility_classification"],
|
| 211 |
-
tab1["construction_era"],
|
| 212 |
-
tab1["assessor_name"],
|
| 213 |
-
tab1["assessor_credentials"],
|
| 214 |
-
],
|
| 215 |
outputs=[
|
| 216 |
session_state,
|
| 217 |
tab1["validation_status"],
|
|
@@ -219,88 +255,56 @@ def create_app() -> gr.Blocks:
|
|
| 219 |
],
|
| 220 |
)
|
| 221 |
|
| 222 |
-
#
|
| 223 |
-
|
| 224 |
-
fn=
|
| 225 |
-
inputs=[tab1["zip_code"]],
|
| 226 |
-
outputs=[tab1["zip_validation"]],
|
| 227 |
-
)
|
| 228 |
-
|
| 229 |
-
# Tab 2: Building/Rooms
|
| 230 |
-
tab2["add_room_btn"].click(
|
| 231 |
-
fn=rooms.add_room,
|
| 232 |
inputs=[
|
| 233 |
session_state,
|
| 234 |
-
tab2["
|
| 235 |
-
tab2["
|
| 236 |
-
tab2["room_length"],
|
| 237 |
-
tab2["room_width"],
|
| 238 |
-
tab2["room_height_preset"],
|
| 239 |
-
tab2["room_height_custom"],
|
| 240 |
],
|
| 241 |
outputs=[
|
| 242 |
session_state,
|
| 243 |
-
tab2["
|
| 244 |
tab2["validation_status"],
|
| 245 |
-
tab2["
|
| 246 |
-
tab2["
|
| 247 |
-
tab2["
|
| 248 |
-
tab2["room_name"],
|
| 249 |
-
tab2["room_floor"],
|
| 250 |
-
tab2["room_length"],
|
| 251 |
-
tab2["room_width"],
|
| 252 |
-
tab2["room_height_preset"],
|
| 253 |
-
tab2["room_height_custom"],
|
| 254 |
],
|
| 255 |
)
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
fn=rooms.on_height_preset_change,
|
| 260 |
-
inputs=[tab2["room_height_preset"]],
|
| 261 |
-
outputs=[tab2["room_height_custom"]],
|
| 262 |
-
)
|
| 263 |
-
|
| 264 |
-
tab2["clear_form_btn"].click(
|
| 265 |
-
fn=lambda: ("", None, None, None, None, None),
|
| 266 |
outputs=[
|
| 267 |
-
tab2["
|
| 268 |
-
tab2["
|
| 269 |
-
tab2["room_length"],
|
| 270 |
-
tab2["room_width"],
|
| 271 |
-
tab2["room_height_preset"],
|
| 272 |
-
tab2["room_height_custom"],
|
| 273 |
],
|
| 274 |
)
|
| 275 |
|
| 276 |
tab2["remove_last_btn"].click(
|
| 277 |
-
fn=
|
| 278 |
inputs=[session_state],
|
| 279 |
outputs=[
|
| 280 |
session_state,
|
| 281 |
-
tab2["
|
| 282 |
tab2["validation_status"],
|
| 283 |
-
tab2["
|
| 284 |
-
tab2["total_area"],
|
| 285 |
-
tab2["total_volume"],
|
| 286 |
],
|
| 287 |
)
|
| 288 |
|
| 289 |
tab2["clear_all_btn"].click(
|
| 290 |
-
fn=
|
| 291 |
inputs=[session_state],
|
| 292 |
outputs=[
|
| 293 |
session_state,
|
| 294 |
-
tab2["
|
| 295 |
tab2["validation_status"],
|
| 296 |
-
tab2["
|
| 297 |
-
tab2["total_area"],
|
| 298 |
-
tab2["total_volume"],
|
| 299 |
],
|
| 300 |
)
|
| 301 |
|
| 302 |
tab2["validate_btn"].click(
|
| 303 |
-
fn=
|
| 304 |
inputs=[session_state],
|
| 305 |
outputs=[
|
| 306 |
session_state,
|
|
@@ -314,59 +318,27 @@ def create_app() -> gr.Blocks:
|
|
| 314 |
outputs=[tabs],
|
| 315 |
)
|
| 316 |
|
| 317 |
-
# Tab 3:
|
| 318 |
-
tab3["
|
| 319 |
-
fn=
|
| 320 |
inputs=[
|
| 321 |
session_state,
|
| 322 |
-
tab3["
|
| 323 |
-
tab3["
|
| 324 |
-
tab3["
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
tab3["
|
| 329 |
-
tab3["
|
| 330 |
-
tab3["
|
| 331 |
-
tab3["
|
| 332 |
-
tab3["
|
| 333 |
-
tab3["
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
tab3["clear_upload_btn"].click(
|
| 338 |
-
fn=lambda: (None, ""),
|
| 339 |
-
outputs=[
|
| 340 |
-
tab3["image_upload"],
|
| 341 |
-
tab3["image_description"],
|
| 342 |
],
|
| 343 |
-
)
|
| 344 |
-
|
| 345 |
-
tab3["remove_last_btn"].click(
|
| 346 |
-
fn=images.remove_last_image,
|
| 347 |
-
inputs=[session_state],
|
| 348 |
-
outputs=[
|
| 349 |
-
session_state,
|
| 350 |
-
tab3["images_gallery"],
|
| 351 |
-
tab3["validation_status"],
|
| 352 |
-
tab3["image_count"],
|
| 353 |
-
],
|
| 354 |
-
)
|
| 355 |
-
|
| 356 |
-
tab3["clear_all_btn"].click(
|
| 357 |
-
fn=images.clear_all_images,
|
| 358 |
-
inputs=[session_state],
|
| 359 |
-
outputs=[
|
| 360 |
-
session_state,
|
| 361 |
-
tab3["images_gallery"],
|
| 362 |
-
tab3["validation_status"],
|
| 363 |
-
tab3["image_count"],
|
| 364 |
-
],
|
| 365 |
-
)
|
| 366 |
-
|
| 367 |
-
tab3["validate_btn"].click(
|
| 368 |
-
fn=images.validate_and_continue,
|
| 369 |
-
inputs=[session_state],
|
| 370 |
outputs=[
|
| 371 |
session_state,
|
| 372 |
tab3["validation_status"],
|
|
@@ -379,72 +351,39 @@ def create_app() -> gr.Blocks:
|
|
| 379 |
outputs=[tabs],
|
| 380 |
)
|
| 381 |
|
| 382 |
-
# Tab 4:
|
| 383 |
-
tab4["
|
| 384 |
-
fn=observations.validate_and_continue,
|
| 385 |
-
inputs=[
|
| 386 |
-
session_state,
|
| 387 |
-
tab4["smoke_odor"],
|
| 388 |
-
tab4["odor_intensity"],
|
| 389 |
-
tab4["visible_soot"],
|
| 390 |
-
tab4["soot_description"],
|
| 391 |
-
tab4["large_char"],
|
| 392 |
-
tab4["char_density"],
|
| 393 |
-
tab4["ash_residue"],
|
| 394 |
-
tab4["ash_description"],
|
| 395 |
-
tab4["surface_discoloration"],
|
| 396 |
-
tab4["discoloration_description"],
|
| 397 |
-
tab4["dust_interference"],
|
| 398 |
-
tab4["dust_notes"],
|
| 399 |
-
tab4["wildfire_indicators"],
|
| 400 |
-
tab4["wildfire_notes"],
|
| 401 |
-
tab4["additional_notes"],
|
| 402 |
-
],
|
| 403 |
-
outputs=[
|
| 404 |
-
session_state,
|
| 405 |
-
tab4["validation_status"],
|
| 406 |
-
tabs,
|
| 407 |
-
],
|
| 408 |
-
)
|
| 409 |
-
|
| 410 |
-
tab4["back_btn"].click(
|
| 411 |
-
fn=lambda: gr.update(selected=2),
|
| 412 |
-
outputs=[tabs],
|
| 413 |
-
)
|
| 414 |
-
|
| 415 |
-
# Tab 5: Generate Results
|
| 416 |
-
tab5["generate_btn"].click(
|
| 417 |
fn=results.generate_assessment,
|
| 418 |
inputs=[session_state],
|
| 419 |
outputs=[
|
| 420 |
session_state,
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
],
|
| 429 |
)
|
| 430 |
|
| 431 |
-
|
| 432 |
fn=results.generate_assessment,
|
| 433 |
inputs=[session_state],
|
| 434 |
outputs=[
|
| 435 |
session_state,
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
],
|
| 444 |
)
|
| 445 |
|
| 446 |
-
|
| 447 |
-
fn=lambda: gr.update(selected=
|
| 448 |
outputs=[tabs],
|
| 449 |
)
|
| 450 |
|
|
@@ -452,84 +391,67 @@ def create_app() -> gr.Blocks:
|
|
| 452 |
# Using Tab.select instead of Tabs.select because Tabs.select doesn't fire in Gradio 6.x
|
| 453 |
# See: https://github.com/gradio-app/gradio/issues/7189
|
| 454 |
|
| 455 |
-
# Tab 1 (
|
| 456 |
-
|
| 457 |
-
fn=
|
| 458 |
inputs=[session_state],
|
| 459 |
outputs=[
|
| 460 |
-
tab1["
|
| 461 |
-
tab1["
|
| 462 |
-
tab1["
|
| 463 |
-
tab1["
|
| 464 |
-
tab1["
|
| 465 |
-
tab1["
|
| 466 |
-
tab1["
|
| 467 |
-
tab1["assessment_date"],
|
| 468 |
tab1["facility_classification"],
|
| 469 |
tab1["construction_era"],
|
| 470 |
-
tab1["assessor_name"],
|
| 471 |
-
tab1["assessor_credentials"],
|
| 472 |
-
],
|
| 473 |
-
)
|
| 474 |
-
|
| 475 |
-
# Tab 2 (Rooms): Load room table and stats when selected
|
| 476 |
-
tab_rooms.select(
|
| 477 |
-
fn=rooms.load_from_session,
|
| 478 |
-
inputs=[session_state],
|
| 479 |
-
outputs=[
|
| 480 |
-
tab2["rooms_table"],
|
| 481 |
-
tab2["room_count"],
|
| 482 |
-
tab2["total_area"],
|
| 483 |
-
tab2["total_volume"],
|
| 484 |
],
|
| 485 |
)
|
| 486 |
|
| 487 |
-
# Tab
|
| 488 |
def load_images_tab(session: SessionState):
|
| 489 |
"""Load all images tab data."""
|
| 490 |
-
room_choices = images.update_room_choices(session)
|
| 491 |
gallery, count, warning = images.load_from_session(session)
|
| 492 |
-
return
|
| 493 |
|
| 494 |
tab_images.select(
|
| 495 |
fn=load_images_tab,
|
| 496 |
inputs=[session_state],
|
| 497 |
outputs=[
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
tab3["resume_warning"],
|
| 502 |
],
|
| 503 |
)
|
| 504 |
|
| 505 |
-
# Tab
|
| 506 |
tab_observations.select(
|
| 507 |
fn=observations.load_form_from_session,
|
| 508 |
inputs=[session_state],
|
| 509 |
outputs=[
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
],
|
| 526 |
)
|
| 527 |
|
| 528 |
-
# Tab
|
| 529 |
tab_results.select(
|
| 530 |
fn=results.check_preflight,
|
| 531 |
inputs=[session_state],
|
| 532 |
-
outputs=[
|
| 533 |
)
|
| 534 |
|
| 535 |
return app
|
|
|
|
| 1 |
"""FDAM AI Pipeline - Fire Damage Assessment Methodology v4.0.1
|
| 2 |
|
| 3 |
Main Gradio application entry point with session state and tab validation.
|
| 4 |
+
MVP Simplification: Single room, 4 tabs (Room, Images, Observations, Results).
|
| 5 |
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
|
|
|
| 18 |
from models.loader import get_models
|
| 19 |
from ui.state import SessionState, create_new_session, session_to_json, session_from_json
|
| 20 |
from ui.storage import get_head_html
|
| 21 |
+
from ui.tabs import room, images, observations, results
|
| 22 |
from ui import samples
|
| 23 |
|
| 24 |
|
| 25 |
+
# Keyboard shortcuts JavaScript (Ctrl+1-4 for tab navigation)
|
| 26 |
KEYBOARD_JS = """
|
| 27 |
<script>
|
| 28 |
document.addEventListener('keydown', (e) => {
|
| 29 |
+
if (e.ctrlKey && e.key >= '1' && e.key <= '4') {
|
| 30 |
e.preventDefault();
|
| 31 |
const tabIds = [
|
| 32 |
+
'tab-room-button', 'tab-images-button',
|
| 33 |
'tab-observations-button', 'tab-results-button'
|
| 34 |
];
|
| 35 |
const tabIndex = parseInt(e.key) - 1;
|
|
|
|
| 74 |
# FDAM AI Pipeline
|
| 75 |
## Fire Damage Assessment Methodology v4.0.1
|
| 76 |
|
| 77 |
+
Upload images and room information to generate a professional
|
| 78 |
Cleaning Specification / Scope of Work.
|
| 79 |
"""
|
| 80 |
)
|
|
|
|
| 106 |
# Tab navigation (elem_id for stable JS selectors - Gradio appends "-button" for tab buttons)
|
| 107 |
# Store Tab references for individual select event handlers
|
| 108 |
with gr.Tabs() as tabs:
|
| 109 |
+
# Tab 1: Room Assessment
|
| 110 |
+
tab_room = gr.Tab("1. Room Assessment", id=0, elem_id="tab-room")
|
| 111 |
+
with tab_room:
|
| 112 |
+
tab1 = room.create_tab()
|
| 113 |
+
|
| 114 |
+
# Tab 2: Images
|
| 115 |
+
tab_images = gr.Tab("2. Images", id=1, elem_id="tab-images")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
with tab_images:
|
| 117 |
+
tab2 = images.create_tab()
|
| 118 |
|
| 119 |
+
# Tab 3: Observations
|
| 120 |
+
tab_observations = gr.Tab("3. Observations", id=2, elem_id="tab-observations")
|
| 121 |
with tab_observations:
|
| 122 |
+
tab3 = observations.create_tab()
|
| 123 |
|
| 124 |
+
# Tab 4: Generate Results
|
| 125 |
+
tab_results = gr.Tab("4. Generate Results", id=3, elem_id="tab-results")
|
| 126 |
with tab_results:
|
| 127 |
+
tab4 = results.create_tab()
|
| 128 |
|
| 129 |
# --- Event Handlers ---
|
| 130 |
|
|
|
|
| 135 |
# Empty selection, do nothing
|
| 136 |
return (
|
| 137 |
current_session, # session_state unchanged
|
| 138 |
+
*room.load_from_session(current_session), # room form values
|
| 139 |
gr.update(), # tabs unchanged
|
| 140 |
"", # clear status
|
| 141 |
"", # reset dropdown
|
|
|
|
| 146 |
if not new_session:
|
| 147 |
return (
|
| 148 |
current_session,
|
| 149 |
+
*room.load_from_session(current_session),
|
| 150 |
gr.update(),
|
| 151 |
'<span style="color: #c62828;">Error: Sample not found</span>',
|
| 152 |
"",
|
|
|
|
| 157 |
name = scenario.name if scenario else scenario_id
|
| 158 |
|
| 159 |
# Load form values from new session
|
| 160 |
+
form_values = room.load_from_session(new_session)
|
| 161 |
|
| 162 |
return (
|
| 163 |
new_session, # updated session_state
|
| 164 |
+
*form_values, # room form values for Tab 1
|
| 165 |
gr.update(selected=0), # switch to Tab 1 (Gradio 6.x syntax)
|
| 166 |
f'<span style="color: #2e7d32;">Loaded sample: {name}</span>',
|
| 167 |
"", # reset dropdown to empty
|
|
|
|
| 172 |
inputs=[sample_dropdown, session_state],
|
| 173 |
outputs=[
|
| 174 |
session_state,
|
| 175 |
+
tab1["room_name"],
|
| 176 |
+
tab1["room_length"],
|
| 177 |
+
tab1["room_width"],
|
| 178 |
+
tab1["room_height_preset"],
|
| 179 |
+
tab1["room_height_custom"],
|
| 180 |
+
tab1["floor_area"],
|
| 181 |
+
tab1["room_volume"],
|
|
|
|
| 182 |
tab1["facility_classification"],
|
| 183 |
tab1["construction_era"],
|
|
|
|
|
|
|
| 184 |
tabs,
|
| 185 |
sample_status,
|
| 186 |
sample_dropdown,
|
| 187 |
],
|
| 188 |
)
|
| 189 |
|
| 190 |
+
# Tab 1: Room Assessment
|
| 191 |
+
|
| 192 |
+
# Save room data on field changes
|
| 193 |
+
def on_room_field_change(
|
| 194 |
+
session: SessionState,
|
| 195 |
+
name: str,
|
| 196 |
+
length: float | None,
|
| 197 |
+
width: float | None,
|
| 198 |
+
height_preset: int | None,
|
| 199 |
+
height_custom: float | None,
|
| 200 |
+
facility_classification: str,
|
| 201 |
+
construction_era: str,
|
| 202 |
+
):
|
| 203 |
+
"""Save room data and update calculated values."""
|
| 204 |
+
updated_session = room.save_room_to_session(
|
| 205 |
+
session, name, length, width, height_preset, height_custom,
|
| 206 |
+
facility_classification, construction_era
|
| 207 |
+
)
|
| 208 |
+
floor_area, volume = room.update_calculated_values(
|
| 209 |
+
length, width, height_preset, height_custom
|
| 210 |
+
)
|
| 211 |
+
return updated_session, floor_area, volume
|
| 212 |
+
|
| 213 |
+
# Wire up all room input fields to save on change
|
| 214 |
+
room_inputs = [
|
| 215 |
+
session_state,
|
| 216 |
+
tab1["room_name"],
|
| 217 |
+
tab1["room_length"],
|
| 218 |
+
tab1["room_width"],
|
| 219 |
+
tab1["room_height_preset"],
|
| 220 |
+
tab1["room_height_custom"],
|
| 221 |
+
tab1["facility_classification"],
|
| 222 |
+
tab1["construction_era"],
|
| 223 |
+
]
|
| 224 |
+
room_outputs = [session_state, tab1["floor_area"], tab1["room_volume"]]
|
| 225 |
+
|
| 226 |
+
for input_component in [
|
| 227 |
+
tab1["room_name"],
|
| 228 |
+
tab1["room_length"],
|
| 229 |
+
tab1["room_width"],
|
| 230 |
+
tab1["room_height_preset"],
|
| 231 |
+
tab1["room_height_custom"],
|
| 232 |
+
tab1["facility_classification"],
|
| 233 |
+
tab1["construction_era"],
|
| 234 |
+
]:
|
| 235 |
+
input_component.change(
|
| 236 |
+
fn=on_room_field_change,
|
| 237 |
+
inputs=room_inputs,
|
| 238 |
+
outputs=room_outputs,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Show/hide custom height input based on preset selection
|
| 242 |
+
tab1["room_height_preset"].change(
|
| 243 |
+
fn=room.on_height_preset_change,
|
| 244 |
+
inputs=[tab1["room_height_preset"]],
|
| 245 |
+
outputs=[tab1["room_height_custom"]],
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
tab1["validate_btn"].click(
|
| 249 |
+
fn=room.validate_and_continue,
|
| 250 |
+
inputs=[session_state],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
outputs=[
|
| 252 |
session_state,
|
| 253 |
tab1["validation_status"],
|
|
|
|
| 255 |
],
|
| 256 |
)
|
| 257 |
|
| 258 |
+
# Tab 2: Images
|
| 259 |
+
tab2["add_image_btn"].click(
|
| 260 |
+
fn=images.add_image,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
inputs=[
|
| 262 |
session_state,
|
| 263 |
+
tab2["image_upload"],
|
| 264 |
+
tab2["image_description"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
],
|
| 266 |
outputs=[
|
| 267 |
session_state,
|
| 268 |
+
tab2["images_gallery"],
|
| 269 |
tab2["validation_status"],
|
| 270 |
+
tab2["image_count"],
|
| 271 |
+
tab2["image_upload"],
|
| 272 |
+
tab2["image_description"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
],
|
| 274 |
)
|
| 275 |
|
| 276 |
+
tab2["clear_upload_btn"].click(
|
| 277 |
+
fn=lambda: (None, ""),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
outputs=[
|
| 279 |
+
tab2["image_upload"],
|
| 280 |
+
tab2["image_description"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
],
|
| 282 |
)
|
| 283 |
|
| 284 |
tab2["remove_last_btn"].click(
|
| 285 |
+
fn=images.remove_last_image,
|
| 286 |
inputs=[session_state],
|
| 287 |
outputs=[
|
| 288 |
session_state,
|
| 289 |
+
tab2["images_gallery"],
|
| 290 |
tab2["validation_status"],
|
| 291 |
+
tab2["image_count"],
|
|
|
|
|
|
|
| 292 |
],
|
| 293 |
)
|
| 294 |
|
| 295 |
tab2["clear_all_btn"].click(
|
| 296 |
+
fn=images.clear_all_images,
|
| 297 |
inputs=[session_state],
|
| 298 |
outputs=[
|
| 299 |
session_state,
|
| 300 |
+
tab2["images_gallery"],
|
| 301 |
tab2["validation_status"],
|
| 302 |
+
tab2["image_count"],
|
|
|
|
|
|
|
| 303 |
],
|
| 304 |
)
|
| 305 |
|
| 306 |
tab2["validate_btn"].click(
|
| 307 |
+
fn=images.validate_and_continue,
|
| 308 |
inputs=[session_state],
|
| 309 |
outputs=[
|
| 310 |
session_state,
|
|
|
|
| 318 |
outputs=[tabs],
|
| 319 |
)
|
| 320 |
|
| 321 |
+
# Tab 3: Observations
|
| 322 |
+
tab3["validate_btn"].click(
|
| 323 |
+
fn=observations.validate_and_continue,
|
| 324 |
inputs=[
|
| 325 |
session_state,
|
| 326 |
+
tab3["smoke_odor"],
|
| 327 |
+
tab3["odor_intensity"],
|
| 328 |
+
tab3["visible_soot"],
|
| 329 |
+
tab3["soot_description"],
|
| 330 |
+
tab3["large_char"],
|
| 331 |
+
tab3["char_density"],
|
| 332 |
+
tab3["ash_residue"],
|
| 333 |
+
tab3["ash_description"],
|
| 334 |
+
tab3["surface_discoloration"],
|
| 335 |
+
tab3["discoloration_description"],
|
| 336 |
+
tab3["dust_interference"],
|
| 337 |
+
tab3["dust_notes"],
|
| 338 |
+
tab3["wildfire_indicators"],
|
| 339 |
+
tab3["wildfire_notes"],
|
| 340 |
+
tab3["additional_notes"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
outputs=[
|
| 343 |
session_state,
|
| 344 |
tab3["validation_status"],
|
|
|
|
| 351 |
outputs=[tabs],
|
| 352 |
)
|
| 353 |
|
| 354 |
+
# Tab 4: Generate Results
|
| 355 |
+
tab4["generate_btn"].click(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
fn=results.generate_assessment,
|
| 357 |
inputs=[session_state],
|
| 358 |
outputs=[
|
| 359 |
session_state,
|
| 360 |
+
tab4["processing_status"],
|
| 361 |
+
tab4["progress_html"],
|
| 362 |
+
tab4["annotated_gallery"],
|
| 363 |
+
tab4["stats_output"],
|
| 364 |
+
tab4["sow_output"],
|
| 365 |
+
tab4["download_md"],
|
| 366 |
+
tab4["download_pdf"],
|
| 367 |
],
|
| 368 |
)
|
| 369 |
|
| 370 |
+
tab4["regenerate_btn"].click(
|
| 371 |
fn=results.generate_assessment,
|
| 372 |
inputs=[session_state],
|
| 373 |
outputs=[
|
| 374 |
session_state,
|
| 375 |
+
tab4["processing_status"],
|
| 376 |
+
tab4["progress_html"],
|
| 377 |
+
tab4["annotated_gallery"],
|
| 378 |
+
tab4["stats_output"],
|
| 379 |
+
tab4["sow_output"],
|
| 380 |
+
tab4["download_md"],
|
| 381 |
+
tab4["download_pdf"],
|
| 382 |
],
|
| 383 |
)
|
| 384 |
|
| 385 |
+
tab4["back_btn"].click(
|
| 386 |
+
fn=lambda: gr.update(selected=2),
|
| 387 |
outputs=[tabs],
|
| 388 |
)
|
| 389 |
|
|
|
|
| 391 |
# Using Tab.select instead of Tabs.select because Tabs.select doesn't fire in Gradio 6.x
|
| 392 |
# See: https://github.com/gradio-app/gradio/issues/7189
|
| 393 |
|
| 394 |
+
# Tab 1 (Room): Load room form fields when selected
|
| 395 |
+
tab_room.select(
|
| 396 |
+
fn=room.load_from_session,
|
| 397 |
inputs=[session_state],
|
| 398 |
outputs=[
|
| 399 |
+
tab1["room_name"],
|
| 400 |
+
tab1["room_length"],
|
| 401 |
+
tab1["room_width"],
|
| 402 |
+
tab1["room_height_preset"],
|
| 403 |
+
tab1["room_height_custom"],
|
| 404 |
+
tab1["floor_area"],
|
| 405 |
+
tab1["room_volume"],
|
|
|
|
| 406 |
tab1["facility_classification"],
|
| 407 |
tab1["construction_era"],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
],
|
| 409 |
)
|
| 410 |
|
| 411 |
+
# Tab 2 (Images): Load gallery and count when selected
|
| 412 |
def load_images_tab(session: SessionState):
|
| 413 |
"""Load all images tab data."""
|
|
|
|
| 414 |
gallery, count, warning = images.load_from_session(session)
|
| 415 |
+
return gallery, count, warning
|
| 416 |
|
| 417 |
tab_images.select(
|
| 418 |
fn=load_images_tab,
|
| 419 |
inputs=[session_state],
|
| 420 |
outputs=[
|
| 421 |
+
tab2["images_gallery"],
|
| 422 |
+
tab2["image_count"],
|
| 423 |
+
tab2["resume_warning"],
|
|
|
|
| 424 |
],
|
| 425 |
)
|
| 426 |
|
| 427 |
+
# Tab 3 (Observations): Load observation form fields when selected
|
| 428 |
tab_observations.select(
|
| 429 |
fn=observations.load_form_from_session,
|
| 430 |
inputs=[session_state],
|
| 431 |
outputs=[
|
| 432 |
+
tab3["smoke_odor"],
|
| 433 |
+
tab3["odor_intensity"],
|
| 434 |
+
tab3["visible_soot"],
|
| 435 |
+
tab3["soot_description"],
|
| 436 |
+
tab3["large_char"],
|
| 437 |
+
tab3["char_density"],
|
| 438 |
+
tab3["ash_residue"],
|
| 439 |
+
tab3["ash_description"],
|
| 440 |
+
tab3["surface_discoloration"],
|
| 441 |
+
tab3["discoloration_description"],
|
| 442 |
+
tab3["dust_interference"],
|
| 443 |
+
tab3["dust_notes"],
|
| 444 |
+
tab3["wildfire_indicators"],
|
| 445 |
+
tab3["wildfire_notes"],
|
| 446 |
+
tab3["additional_notes"],
|
| 447 |
],
|
| 448 |
)
|
| 449 |
|
| 450 |
+
# Tab 4 (Results): Check preflight status when selected
|
| 451 |
tab_results.select(
|
| 452 |
fn=results.check_preflight,
|
| 453 |
inputs=[session_state],
|
| 454 |
+
outputs=[tab4["preflight_status"]],
|
| 455 |
)
|
| 456 |
|
| 457 |
return app
|
pipeline/calculations.py
CHANGED
|
@@ -277,23 +277,19 @@ class FDAMCalculator:
|
|
| 277 |
"""Run all calculations from a session state.
|
| 278 |
|
| 279 |
Args:
|
| 280 |
-
session: Current session state with
|
| 281 |
|
| 282 |
Returns:
|
| 283 |
Dictionary with all calculation results
|
| 284 |
"""
|
| 285 |
-
|
|
|
|
| 286 |
|
| 287 |
-
# Calculate totals from
|
| 288 |
-
total_area =
|
| 289 |
-
total_volume =
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
)
|
| 293 |
-
avg_ceiling = (
|
| 294 |
-
total_volume / total_area if total_area > 0 else 10.0
|
| 295 |
-
)
|
| 296 |
-
logger.debug(f"Totals: {total_area:.0f} SF, {total_volume:.0f} CF, avg ceiling {avg_ceiling:.1f} ft")
|
| 297 |
|
| 298 |
# Air filtration
|
| 299 |
air_filtration = self.calculate_air_filtration(
|
|
@@ -313,8 +309,8 @@ class FDAMCalculator:
|
|
| 313 |
|
| 314 |
# Regulatory flags
|
| 315 |
regulatory = self.get_regulatory_flags(
|
| 316 |
-
construction_era=
|
| 317 |
-
facility_classification=
|
| 318 |
)
|
| 319 |
if regulatory.notes:
|
| 320 |
for note in regulatory.notes:
|
|
@@ -322,7 +318,7 @@ class FDAMCalculator:
|
|
| 322 |
|
| 323 |
# Metals thresholds
|
| 324 |
thresholds = self.get_metals_thresholds(
|
| 325 |
-
facility_classification=
|
| 326 |
)
|
| 327 |
logger.debug(f"Metals thresholds ({thresholds.facility_type}): Pb={thresholds.lead_ug_100cm2} µg/100cm²")
|
| 328 |
|
|
|
|
| 277 |
"""Run all calculations from a session state.
|
| 278 |
|
| 279 |
Args:
|
| 280 |
+
session: Current session state with room info
|
| 281 |
|
| 282 |
Returns:
|
| 283 |
Dictionary with all calculation results
|
| 284 |
"""
|
| 285 |
+
r = session.room
|
| 286 |
+
logger.debug(f"Running calculations for room: {r.name}")
|
| 287 |
|
| 288 |
+
# Calculate totals from single room
|
| 289 |
+
total_area = r.length_ft * r.width_ft
|
| 290 |
+
total_volume = total_area * r.ceiling_height_ft
|
| 291 |
+
avg_ceiling = r.ceiling_height_ft if r.ceiling_height_ft > 0 else 10.0
|
| 292 |
+
logger.debug(f"Totals: {total_area:.0f} SF, {total_volume:.0f} CF, ceiling {avg_ceiling:.1f} ft")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
# Air filtration
|
| 295 |
air_filtration = self.calculate_air_filtration(
|
|
|
|
| 309 |
|
| 310 |
# Regulatory flags
|
| 311 |
regulatory = self.get_regulatory_flags(
|
| 312 |
+
construction_era=r.construction_era or "post-2000",
|
| 313 |
+
facility_classification=r.facility_classification or "non-operational",
|
| 314 |
)
|
| 315 |
if regulatory.notes:
|
| 316 |
for note in regulatory.notes:
|
|
|
|
| 318 |
|
| 319 |
# Metals thresholds
|
| 320 |
thresholds = self.get_metals_thresholds(
|
| 321 |
+
facility_classification=r.facility_classification or "non-operational",
|
| 322 |
)
|
| 323 |
logger.debug(f"Metals thresholds ({thresholds.facility_type}): Pb={thresholds.lead_ug_100cm2} µg/100cm²")
|
| 324 |
|
pipeline/generator.py
CHANGED
|
@@ -141,11 +141,11 @@ class DocumentGenerator:
|
|
| 141 |
|
| 142 |
return GeneratedDocument(
|
| 143 |
markdown=markdown,
|
| 144 |
-
title=f"SOW - {session.
|
| 145 |
generated_at=datetime.now().isoformat(),
|
| 146 |
word_count=word_count,
|
| 147 |
sections=[
|
| 148 |
-
"Header", "
|
| 149 |
"Vision Analysis", "Observations", "Dispositions",
|
| 150 |
"Cleaning Specs", "Air Filtration", "Sampling Plan",
|
| 151 |
"Regulatory", "Thresholds", "Footer"
|
|
@@ -156,26 +156,20 @@ class DocumentGenerator:
|
|
| 156 |
"""Generate document header."""
|
| 157 |
return f"""# Cleaning Specification / Scope of Work
|
| 158 |
|
| 159 |
-
**
|
| 160 |
-
**Prepared For:** {session.project.client_name}
|
| 161 |
**Date:** {datetime.now().strftime('%B %d, %Y')}
|
| 162 |
**Document Version:** FDAM v4.0.1"""
|
| 163 |
|
| 164 |
def _generate_project_info(self, session: SessionState) -> str:
|
| 165 |
-
"""Generate
|
| 166 |
-
|
| 167 |
-
return f"""##
|
| 168 |
|
| 169 |
| Field | Value |
|
| 170 |
|-------|-------|
|
| 171 |
-
| **
|
| 172 |
-
| **
|
| 173 |
-
| **
|
| 174 |
-
| **Fire Date** | {p.fire_date} |
|
| 175 |
-
| **Assessment Date** | {p.assessment_date} |
|
| 176 |
-
| **Facility Classification** | {p.facility_classification or 'Not specified'} |
|
| 177 |
-
| **Construction Era** | {p.construction_era or 'Not specified'} |
|
| 178 |
-
| **Assessor** | {p.assessor_name} {p.assessor_credentials or ''} |"""
|
| 179 |
|
| 180 |
def _generate_scope_summary(self, session: SessionState, calculations: dict) -> str:
|
| 181 |
"""Generate scope summary section."""
|
|
@@ -186,7 +180,7 @@ class DocumentGenerator:
|
|
| 186 |
|
| 187 |
| Metric | Value |
|
| 188 |
|--------|-------|
|
| 189 |
-
| **
|
| 190 |
| **Total Floor Area** | {calculations['total_area_sf']:,.0f} SF |
|
| 191 |
| **Total Volume** | {calculations['total_volume_cf']:,.0f} CF |
|
| 192 |
| **Images Analyzed** | {len(session.images)} |
|
|
@@ -195,20 +189,21 @@ class DocumentGenerator:
|
|
| 195 |
| **Est. Surface Wipes** | {sample.surface_wipes_min}-{sample.surface_wipes_max if sample else 'N/A'} |"""
|
| 196 |
|
| 197 |
def _generate_room_inventory(self, session: SessionState) -> str:
|
| 198 |
-
"""Generate room
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
| 212 |
|
| 213 |
def _generate_vision_summary(self, session: SessionState, vision_results: dict) -> str:
|
| 214 |
"""Generate AI vision analysis summary."""
|
|
|
|
| 141 |
|
| 142 |
return GeneratedDocument(
|
| 143 |
markdown=markdown,
|
| 144 |
+
title=f"SOW - {session.room.name}",
|
| 145 |
generated_at=datetime.now().isoformat(),
|
| 146 |
word_count=word_count,
|
| 147 |
sections=[
|
| 148 |
+
"Header", "Room Info", "Scope Summary", "Room Details",
|
| 149 |
"Vision Analysis", "Observations", "Dispositions",
|
| 150 |
"Cleaning Specs", "Air Filtration", "Sampling Plan",
|
| 151 |
"Regulatory", "Thresholds", "Footer"
|
|
|
|
| 156 |
"""Generate document header."""
|
| 157 |
return f"""# Cleaning Specification / Scope of Work
|
| 158 |
|
| 159 |
+
**Room:** {session.room.name}
|
|
|
|
| 160 |
**Date:** {datetime.now().strftime('%B %d, %Y')}
|
| 161 |
**Document Version:** FDAM v4.0.1"""
|
| 162 |
|
| 163 |
def _generate_project_info(self, session: SessionState) -> str:
|
| 164 |
+
"""Generate room information section."""
|
| 165 |
+
r = session.room
|
| 166 |
+
return f"""## Room Information
|
| 167 |
|
| 168 |
| Field | Value |
|
| 169 |
|-------|-------|
|
| 170 |
+
| **Room Name** | {r.name} |
|
| 171 |
+
| **Facility Classification** | {r.facility_classification or 'Not specified'} |
|
| 172 |
+
| **Construction Era** | {r.construction_era or 'Not specified'} |"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
def _generate_scope_summary(self, session: SessionState, calculations: dict) -> str:
|
| 175 |
"""Generate scope summary section."""
|
|
|
|
| 180 |
|
| 181 |
| Metric | Value |
|
| 182 |
|--------|-------|
|
| 183 |
+
| **Room** | {session.room.name} |
|
| 184 |
| **Total Floor Area** | {calculations['total_area_sf']:,.0f} SF |
|
| 185 |
| **Total Volume** | {calculations['total_volume_cf']:,.0f} CF |
|
| 186 |
| **Images Analyzed** | {len(session.images)} |
|
|
|
|
| 189 |
| **Est. Surface Wipes** | {sample.surface_wipes_min}-{sample.surface_wipes_max if sample else 'N/A'} |"""
|
| 190 |
|
| 191 |
def _generate_room_inventory(self, session: SessionState) -> str:
|
| 192 |
+
"""Generate room details section."""
|
| 193 |
+
r = session.room
|
| 194 |
+
area = r.length_ft * r.width_ft
|
| 195 |
+
volume = area * r.ceiling_height_ft
|
| 196 |
+
|
| 197 |
+
return f"""## Room Details
|
| 198 |
+
|
| 199 |
+
| Property | Value |
|
| 200 |
+
|----------|-------|
|
| 201 |
+
| **Room Name** | {r.name} |
|
| 202 |
+
| **Dimensions** | {r.length_ft:.0f}' × {r.width_ft:.0f}' × {r.ceiling_height_ft:.0f}' |
|
| 203 |
+
| **Floor Area** | {area:,.0f} SF |
|
| 204 |
+
| **Volume** | {volume:,.0f} CF |
|
| 205 |
+
| **Facility Type** | {r.facility_classification or 'Not specified'} |
|
| 206 |
+
| **Construction Era** | {r.construction_era or 'Not specified'} |"""
|
| 207 |
|
| 208 |
def _generate_vision_summary(self, session: SessionState, vision_results: dict) -> str:
|
| 209 |
"""Generate AI vision analysis summary."""
|
pipeline/main.py
CHANGED
|
@@ -145,9 +145,9 @@ class FDAMPipeline:
|
|
| 145 |
logger.info("=" * 60)
|
| 146 |
logger.info("FDAM PIPELINE EXECUTION STARTED")
|
| 147 |
logger.info("=" * 60)
|
| 148 |
-
logger.info(f"
|
| 149 |
-
logger.info(f"Facility: {session.
|
| 150 |
-
logger.info(f"
|
| 151 |
|
| 152 |
def report_progress(stage: int, message: str = ""):
|
| 153 |
if progress_callback:
|
|
@@ -236,14 +236,10 @@ class FDAMPipeline:
|
|
| 236 |
)
|
| 237 |
vision_results[img_meta.id] = vision_result
|
| 238 |
|
| 239 |
-
# Build room mapping
|
| 240 |
-
room_info = next(
|
| 241 |
-
(r for r in session.rooms if r.id == img_meta.room_id),
|
| 242 |
-
None,
|
| 243 |
-
)
|
| 244 |
room_mapping[img_meta.id] = {
|
| 245 |
-
"name":
|
| 246 |
-
"id":
|
| 247 |
}
|
| 248 |
|
| 249 |
# Create annotated image caption
|
|
@@ -389,10 +385,9 @@ class FDAMPipeline:
|
|
| 389 |
disp_counts[d.disposition] = disp_counts.get(d.disposition, 0) + 1
|
| 390 |
|
| 391 |
return {
|
| 392 |
-
"
|
| 393 |
-
"facility_classification": result.session.
|
| 394 |
-
"construction_era": result.session.
|
| 395 |
-
"total_rooms": len(result.session.rooms),
|
| 396 |
"total_images": len(result.session.images),
|
| 397 |
"images_analyzed": len(result.vision_results),
|
| 398 |
"total_floor_area_sf": f"{calc.get('total_area_sf', 0):,.0f}",
|
|
|
|
| 145 |
logger.info("=" * 60)
|
| 146 |
logger.info("FDAM PIPELINE EXECUTION STARTED")
|
| 147 |
logger.info("=" * 60)
|
| 148 |
+
logger.info(f"Room: {session.room.name}")
|
| 149 |
+
logger.info(f"Facility: {session.room.facility_classification}")
|
| 150 |
+
logger.info(f"Images: {len(session.images)}")
|
| 151 |
|
| 152 |
def report_progress(stage: int, message: str = ""):
|
| 153 |
if progress_callback:
|
|
|
|
| 236 |
)
|
| 237 |
vision_results[img_meta.id] = vision_result
|
| 238 |
|
| 239 |
+
# Build room mapping (single room)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
room_mapping[img_meta.id] = {
|
| 241 |
+
"name": session.room.name,
|
| 242 |
+
"id": session.room.id,
|
| 243 |
}
|
| 244 |
|
| 245 |
# Create annotated image caption
|
|
|
|
| 385 |
disp_counts[d.disposition] = disp_counts.get(d.disposition, 0) + 1
|
| 386 |
|
| 387 |
return {
|
| 388 |
+
"room_name": result.session.room.name,
|
| 389 |
+
"facility_classification": result.session.room.facility_classification,
|
| 390 |
+
"construction_era": result.session.room.construction_era,
|
|
|
|
| 391 |
"total_images": len(result.session.images),
|
| 392 |
"images_analyzed": len(result.vision_results),
|
| 393 |
"total_floor_area_sf": f"{calc.get('total_area_sf', 0):,.0f}",
|
tests/__init__.py
DELETED
|
File without changes
|
tests/conftest.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
| 1 |
-
"""Pytest fixtures for Playwright E2E tests."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
import subprocess
|
| 5 |
-
import time
|
| 6 |
-
import os
|
| 7 |
-
import urllib.request
|
| 8 |
-
import urllib.error
|
| 9 |
-
from playwright.sync_api import sync_playwright, Browser, Page
|
| 10 |
-
|
| 11 |
-
GRADIO_PORT = 7860
|
| 12 |
-
GRADIO_URL = f"http://localhost:{GRADIO_PORT}"
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
@pytest.fixture(scope="session")
|
| 16 |
-
def gradio_server():
|
| 17 |
-
"""Start Gradio server for E2E tests."""
|
| 18 |
-
env = os.environ.copy()
|
| 19 |
-
env["MOCK_MODELS"] = "true"
|
| 20 |
-
|
| 21 |
-
# Use venv python for consistent environment
|
| 22 |
-
python_cmd = ".venv/bin/python" if os.path.exists(".venv/bin/python") else "python3"
|
| 23 |
-
|
| 24 |
-
# Don't capture output - let it go to console for debugging
|
| 25 |
-
process = subprocess.Popen(
|
| 26 |
-
[python_cmd, "app.py"],
|
| 27 |
-
env=env,
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
# Wait for server to start with health check
|
| 31 |
-
max_retries = 30
|
| 32 |
-
for i in range(max_retries):
|
| 33 |
-
try:
|
| 34 |
-
urllib.request.urlopen(GRADIO_URL, timeout=1)
|
| 35 |
-
break
|
| 36 |
-
except (urllib.error.URLError, ConnectionRefusedError):
|
| 37 |
-
time.sleep(1)
|
| 38 |
-
else:
|
| 39 |
-
process.terminate()
|
| 40 |
-
raise RuntimeError("Gradio server failed to start")
|
| 41 |
-
|
| 42 |
-
yield GRADIO_URL
|
| 43 |
-
|
| 44 |
-
process.terminate()
|
| 45 |
-
process.wait()
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
@pytest.fixture(scope="session")
|
| 49 |
-
def browser_instance():
|
| 50 |
-
"""Create browser instance for the session."""
|
| 51 |
-
# Use headless mode for WSL/CI environments, headed for local debugging
|
| 52 |
-
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "true").lower() == "true"
|
| 53 |
-
|
| 54 |
-
with sync_playwright() as p:
|
| 55 |
-
browser = p.chromium.launch(headless=headless)
|
| 56 |
-
yield browser
|
| 57 |
-
browser.close()
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
@pytest.fixture
|
| 61 |
-
def page(browser_instance, gradio_server):
|
| 62 |
-
"""Create new page for each test."""
|
| 63 |
-
context = browser_instance.new_context()
|
| 64 |
-
page = context.new_page()
|
| 65 |
-
page.goto(gradio_server)
|
| 66 |
-
page.wait_for_selector("#sample_dropdown", timeout=10000)
|
| 67 |
-
yield page
|
| 68 |
-
context.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_e2e_forms.py
DELETED
|
@@ -1,307 +0,0 @@
|
|
| 1 |
-
"""E2E tests for form interactions."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from playwright.sync_api import Page, expect
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def select_sample(page: Page, sample_text: str):
|
| 8 |
-
"""Helper to select a sample from the dropdown."""
|
| 9 |
-
page.locator("#sample_dropdown input[role='listbox']").click()
|
| 10 |
-
page.wait_for_timeout(300)
|
| 11 |
-
page.locator(f"[role='option']:has-text('{sample_text}')").click()
|
| 12 |
-
# Wait for form to be populated - check that project_name has a value
|
| 13 |
-
# (status gets cleared when dropdown resets, so we can't rely on it)
|
| 14 |
-
page.wait_for_function(
|
| 15 |
-
"""() => {
|
| 16 |
-
const textarea = document.querySelector('#project_name textarea');
|
| 17 |
-
return textarea && textarea.value && textarea.value.includes('Sample:');
|
| 18 |
-
}""",
|
| 19 |
-
timeout=30000
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class TestProjectForm:
|
| 24 |
-
"""Test Tab 1 project form."""
|
| 25 |
-
|
| 26 |
-
def test_zip_validation_invalid(self, page: Page):
|
| 27 |
-
"""Test ZIP code format validation with invalid input."""
|
| 28 |
-
# zip_code uses input (max_lines=1), not textarea
|
| 29 |
-
zip_input = page.locator("#zip_code input")
|
| 30 |
-
|
| 31 |
-
# Invalid ZIP (too short)
|
| 32 |
-
zip_input.fill("123")
|
| 33 |
-
zip_input.blur()
|
| 34 |
-
page.wait_for_timeout(300)
|
| 35 |
-
|
| 36 |
-
# Should show invalid message (✗ indicates error)
|
| 37 |
-
expect(page.locator("#zip_validation")).to_contain_text("✗")
|
| 38 |
-
|
| 39 |
-
def test_zip_validation_valid(self, page: Page):
|
| 40 |
-
"""Test ZIP code format validation with valid input."""
|
| 41 |
-
# zip_code uses input (max_lines=1), not textarea
|
| 42 |
-
zip_input = page.locator("#zip_code input")
|
| 43 |
-
|
| 44 |
-
# Valid 5-digit ZIP
|
| 45 |
-
zip_input.fill("12345")
|
| 46 |
-
zip_input.blur()
|
| 47 |
-
page.wait_for_timeout(300)
|
| 48 |
-
|
| 49 |
-
# Should show valid checkmark
|
| 50 |
-
expect(page.locator("#zip_validation")).to_contain_text("Valid")
|
| 51 |
-
|
| 52 |
-
def test_zip_validation_valid_plus4(self, page: Page):
|
| 53 |
-
"""Test ZIP+4 format validation."""
|
| 54 |
-
# zip_code uses input (max_lines=1), not textarea
|
| 55 |
-
zip_input = page.locator("#zip_code input")
|
| 56 |
-
|
| 57 |
-
# Valid ZIP+4
|
| 58 |
-
zip_input.fill("12345-6789")
|
| 59 |
-
zip_input.blur()
|
| 60 |
-
page.wait_for_timeout(300)
|
| 61 |
-
|
| 62 |
-
expect(page.locator("#zip_validation")).to_contain_text("Valid")
|
| 63 |
-
|
| 64 |
-
def test_facility_classification_radio(self, page: Page):
|
| 65 |
-
"""Test facility classification radio buttons."""
|
| 66 |
-
# Use specific selector by value attribute to avoid substring matching
|
| 67 |
-
facility_group = page.locator("#facility_classification")
|
| 68 |
-
|
| 69 |
-
# Click Operational radio (use value attribute for exact match)
|
| 70 |
-
facility_group.locator("input[value='Operational']").click()
|
| 71 |
-
page.wait_for_timeout(200)
|
| 72 |
-
|
| 73 |
-
# Verify it's selected
|
| 74 |
-
expect(facility_group.locator("input[value='Operational']")).to_be_checked()
|
| 75 |
-
|
| 76 |
-
# Click Non-Operational
|
| 77 |
-
facility_group.locator("input[value='Non-Operational']").click()
|
| 78 |
-
page.wait_for_timeout(200)
|
| 79 |
-
|
| 80 |
-
expect(facility_group.locator("input[value='Non-Operational']")).to_be_checked()
|
| 81 |
-
expect(facility_group.locator("input[value='Operational']")).not_to_be_checked()
|
| 82 |
-
|
| 83 |
-
def test_construction_era_radio(self, page: Page):
|
| 84 |
-
"""Test construction era radio buttons."""
|
| 85 |
-
page.get_by_label("Pre-1980").click()
|
| 86 |
-
expect(page.get_by_label("Pre-1980")).to_be_checked()
|
| 87 |
-
|
| 88 |
-
page.get_by_label("Post-2000").click()
|
| 89 |
-
expect(page.get_by_label("Post-2000")).to_be_checked()
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
class TestRoomsForm:
|
| 93 |
-
"""Test Tab 2 rooms form."""
|
| 94 |
-
|
| 95 |
-
def test_room_exists_after_sample_load(self, page: Page):
|
| 96 |
-
"""Test room is created when sample is loaded."""
|
| 97 |
-
select_sample(page, "Bar & Dining Area")
|
| 98 |
-
|
| 99 |
-
# Go to Rooms tab
|
| 100 |
-
page.locator("#tab-rooms-button").click()
|
| 101 |
-
page.wait_for_timeout(500)
|
| 102 |
-
|
| 103 |
-
# Room should exist in table
|
| 104 |
-
expect(page.locator("#rooms_table")).to_contain_text("Bar & Dining Area")
|
| 105 |
-
|
| 106 |
-
def test_custom_height_visibility_toggle(self, page: Page):
|
| 107 |
-
"""Test custom height field appears when 'Custom' selected."""
|
| 108 |
-
page.locator("#tab-rooms-button").click()
|
| 109 |
-
page.wait_for_timeout(300)
|
| 110 |
-
|
| 111 |
-
# Select a standard height first - click dropdown input to open
|
| 112 |
-
dropdown_input = page.locator("#room_height_preset input[role='listbox']")
|
| 113 |
-
dropdown_input.click()
|
| 114 |
-
page.wait_for_timeout(300)
|
| 115 |
-
page.locator("[role='option']:has-text('10 ft')").click()
|
| 116 |
-
page.wait_for_timeout(300)
|
| 117 |
-
|
| 118 |
-
# Custom height should be hidden
|
| 119 |
-
expect(page.locator("#room_height_custom")).not_to_be_visible()
|
| 120 |
-
|
| 121 |
-
# Select Custom - click dropdown input to open
|
| 122 |
-
dropdown_input.click()
|
| 123 |
-
page.wait_for_timeout(300)
|
| 124 |
-
page.locator("[role='option']:has-text('Custom')").click()
|
| 125 |
-
page.wait_for_timeout(300)
|
| 126 |
-
|
| 127 |
-
# Custom height should appear
|
| 128 |
-
expect(page.locator("#room_height_custom")).to_be_visible()
|
| 129 |
-
|
| 130 |
-
def test_room_validation_requires_name(self, page: Page):
|
| 131 |
-
"""Test that room name is required."""
|
| 132 |
-
page.locator("#tab-rooms-button").click()
|
| 133 |
-
page.wait_for_timeout(300)
|
| 134 |
-
|
| 135 |
-
# Try to add room with empty name
|
| 136 |
-
page.locator("#room_length input").fill("20")
|
| 137 |
-
page.locator("#room_width input").fill("15")
|
| 138 |
-
|
| 139 |
-
# Select height from dropdown - click the input to open
|
| 140 |
-
page.locator("#room_height_preset input[role='listbox']").click()
|
| 141 |
-
page.wait_for_timeout(300)
|
| 142 |
-
page.locator("[role='option']:has-text('10 ft')").click()
|
| 143 |
-
page.wait_for_timeout(300)
|
| 144 |
-
|
| 145 |
-
page.get_by_role("button", name="Add Room").click()
|
| 146 |
-
page.wait_for_timeout(300)
|
| 147 |
-
|
| 148 |
-
# Should show validation error about room name
|
| 149 |
-
expect(page.locator("#tab2_validation")).to_contain_text("Room name")
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
class TestImagesForm:
|
| 153 |
-
"""Test Tab 3 images form."""
|
| 154 |
-
|
| 155 |
-
def test_images_gallery_shows_sample_images(self, page: Page):
|
| 156 |
-
"""Test gallery displays images after sample load."""
|
| 157 |
-
select_sample(page, "Bar & Dining Area")
|
| 158 |
-
|
| 159 |
-
# Go to Images tab
|
| 160 |
-
page.locator("#tab-images-button").click()
|
| 161 |
-
page.wait_for_timeout(500)
|
| 162 |
-
|
| 163 |
-
# Gallery should have images
|
| 164 |
-
gallery = page.locator("#images_gallery")
|
| 165 |
-
expect(gallery).to_be_visible()
|
| 166 |
-
|
| 167 |
-
# Should have 3 images
|
| 168 |
-
images = gallery.locator("img")
|
| 169 |
-
expect(images).to_have_count(3)
|
| 170 |
-
|
| 171 |
-
def test_room_dropdown_populated(self, page: Page):
|
| 172 |
-
"""Test room dropdown is populated after sample load."""
|
| 173 |
-
select_sample(page, "Bar & Dining Area")
|
| 174 |
-
|
| 175 |
-
# Go to Images tab
|
| 176 |
-
page.locator("#tab-images-button").click()
|
| 177 |
-
page.wait_for_timeout(500)
|
| 178 |
-
|
| 179 |
-
# Click dropdown input to open it and verify room is in options
|
| 180 |
-
page.locator("#room_select input[role='listbox']").click()
|
| 181 |
-
page.wait_for_timeout(300)
|
| 182 |
-
|
| 183 |
-
# Room should appear in dropdown options
|
| 184 |
-
expect(page.locator("[role='option']:has-text('Bar & Dining Area')")).to_be_visible()
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
class TestObservationsForm:
|
| 188 |
-
"""Test Tab 4 observations form."""
|
| 189 |
-
|
| 190 |
-
def test_checkbox_interactions(self, page: Page):
|
| 191 |
-
"""Test observation checkboxes can be toggled."""
|
| 192 |
-
page.locator("#tab-observations-button").click()
|
| 193 |
-
page.wait_for_timeout(300)
|
| 194 |
-
|
| 195 |
-
# Check smoke odor
|
| 196 |
-
smoke_checkbox = page.locator("#smoke_odor input[type='checkbox']")
|
| 197 |
-
smoke_checkbox.check()
|
| 198 |
-
expect(smoke_checkbox).to_be_checked()
|
| 199 |
-
|
| 200 |
-
# Uncheck
|
| 201 |
-
smoke_checkbox.uncheck()
|
| 202 |
-
expect(smoke_checkbox).not_to_be_checked()
|
| 203 |
-
|
| 204 |
-
def test_odor_intensity_radio(self, page: Page):
|
| 205 |
-
"""Test odor intensity radio buttons."""
|
| 206 |
-
page.locator("#tab-observations-button").click()
|
| 207 |
-
page.wait_for_timeout(300)
|
| 208 |
-
|
| 209 |
-
# Use specific selector within odor_intensity group to avoid matching char_density
|
| 210 |
-
odor_group = page.locator("#odor_intensity")
|
| 211 |
-
|
| 212 |
-
odor_group.get_by_label("Strong").click()
|
| 213 |
-
expect(odor_group.get_by_label("Strong")).to_be_checked()
|
| 214 |
-
|
| 215 |
-
odor_group.get_by_label("Moderate").click()
|
| 216 |
-
expect(odor_group.get_by_label("Moderate")).to_be_checked()
|
| 217 |
-
expect(odor_group.get_by_label("Strong")).not_to_be_checked()
|
| 218 |
-
|
| 219 |
-
def test_observations_persist_after_sample_load(self, page: Page):
|
| 220 |
-
"""Test observations are populated from sample."""
|
| 221 |
-
select_sample(page, "Factory Area")
|
| 222 |
-
|
| 223 |
-
# Go to Observations tab
|
| 224 |
-
page.locator("#tab-observations-button").click()
|
| 225 |
-
page.wait_for_timeout(500)
|
| 226 |
-
|
| 227 |
-
# Factory sample has smoke odor = True
|
| 228 |
-
smoke_checkbox = page.locator("#smoke_odor input[type='checkbox']")
|
| 229 |
-
expect(smoke_checkbox).to_be_checked()
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
class TestDebugSelectors:
|
| 233 |
-
"""Debug tests to verify Gradio HTML structure."""
|
| 234 |
-
|
| 235 |
-
def test_capture_dropdown_and_sample_load(self, page: Page):
|
| 236 |
-
"""Capture dropdown HTML and test full sample load.
|
| 237 |
-
|
| 238 |
-
Run with: pytest tests/test_e2e_forms.py::TestDebugSelectors::test_capture_dropdown_and_sample_load -v -s
|
| 239 |
-
"""
|
| 240 |
-
page.wait_for_timeout(2000)
|
| 241 |
-
|
| 242 |
-
# Click dropdown to open
|
| 243 |
-
print("\n--- Opening dropdown ---")
|
| 244 |
-
dropdown_input = page.locator("#sample_dropdown input[role='listbox']")
|
| 245 |
-
dropdown_input.click()
|
| 246 |
-
page.wait_for_timeout(500)
|
| 247 |
-
|
| 248 |
-
# Click the Bar & Dining option
|
| 249 |
-
print("Clicking Bar & Dining option...")
|
| 250 |
-
page.locator("[role='option']:has-text('Bar & Dining Area')").click()
|
| 251 |
-
page.wait_for_timeout(2000) # Wait for sample to load
|
| 252 |
-
|
| 253 |
-
# Check sample_status HTML
|
| 254 |
-
print("\n--- DEBUG: Status HTML ---")
|
| 255 |
-
try:
|
| 256 |
-
status_html = page.locator("#sample_status").evaluate("el => el.outerHTML")
|
| 257 |
-
print("Sample status HTML:", status_html[:500])
|
| 258 |
-
except Exception as e:
|
| 259 |
-
print(f"Error getting status: {e}")
|
| 260 |
-
|
| 261 |
-
# Check if project name was populated
|
| 262 |
-
print("\n--- DEBUG: Project Name ---")
|
| 263 |
-
try:
|
| 264 |
-
# Get the full HTML of project_name element
|
| 265 |
-
pn_html = page.locator("#project_name").evaluate("el => el.outerHTML")
|
| 266 |
-
print(f"Project name HTML:\n{pn_html[:800]}")
|
| 267 |
-
|
| 268 |
-
# Try different selectors
|
| 269 |
-
print("\nTrying different selectors:")
|
| 270 |
-
print(f" #project_name input count: {page.locator('#project_name input').count()}")
|
| 271 |
-
print(f" #project_name textarea count: {page.locator('#project_name textarea').count()}")
|
| 272 |
-
print(f" #project_name [data-testid] count: {page.locator('#project_name [data-testid]').count()}")
|
| 273 |
-
|
| 274 |
-
# Check if there's any input/textarea in the document
|
| 275 |
-
all_inputs = page.locator("input[type='text']").count()
|
| 276 |
-
print(f" Total text inputs on page: {all_inputs}")
|
| 277 |
-
|
| 278 |
-
except Exception as e:
|
| 279 |
-
print(f"Error: {e}")
|
| 280 |
-
|
| 281 |
-
print("--- END DEBUG ---\n")
|
| 282 |
-
|
| 283 |
-
def test_capture_full_page_structure(self, page: Page):
|
| 284 |
-
"""Capture key element structures.
|
| 285 |
-
|
| 286 |
-
Run with: pytest tests/test_e2e_forms.py::TestDebugSelectors::test_capture_full_page_structure -v -s
|
| 287 |
-
"""
|
| 288 |
-
page.wait_for_timeout(2000)
|
| 289 |
-
|
| 290 |
-
print("\n--- DEBUG: Page Structure ---")
|
| 291 |
-
|
| 292 |
-
# Capture project name structure
|
| 293 |
-
project_name = page.locator("#project_name")
|
| 294 |
-
print("Project name HTML:", project_name.evaluate("el => el.outerHTML")[:500])
|
| 295 |
-
|
| 296 |
-
# Capture tab button structure
|
| 297 |
-
try:
|
| 298 |
-
tab_btn = page.locator("#tab-project-button")
|
| 299 |
-
print("Tab button exists:", tab_btn.count() > 0)
|
| 300 |
-
except Exception as e:
|
| 301 |
-
print(f"Tab button error: {e}")
|
| 302 |
-
|
| 303 |
-
# Try to find tab by different selectors
|
| 304 |
-
tabs = page.locator('[role="tab"]').all()
|
| 305 |
-
print(f"Found {len(tabs)} tabs with role=tab")
|
| 306 |
-
|
| 307 |
-
print("--- END DEBUG ---\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_e2e_samples.py
DELETED
|
@@ -1,148 +0,0 @@
|
|
| 1 |
-
"""E2E tests for sample scenario loading."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from playwright.sync_api import Page, expect
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def select_sample(page: Page, sample_text: str):
|
| 8 |
-
"""Helper to select a sample from the dropdown.
|
| 9 |
-
|
| 10 |
-
Args:
|
| 11 |
-
page: Playwright page
|
| 12 |
-
sample_text: Text of the sample option to select (e.g., "Bar & Dining Area")
|
| 13 |
-
"""
|
| 14 |
-
# Gradio dropdowns use input[role='listbox'] - click to open
|
| 15 |
-
page.locator("#sample_dropdown input[role='listbox']").click()
|
| 16 |
-
page.wait_for_timeout(300) # Wait for dropdown to open
|
| 17 |
-
|
| 18 |
-
# Click the option with matching text
|
| 19 |
-
page.locator(f"[role='option']:has-text('{sample_text}')").click()
|
| 20 |
-
|
| 21 |
-
# Wait for form to be populated - check that project_name has a value
|
| 22 |
-
# Gradio 6.x uses textarea for textboxes
|
| 23 |
-
page.wait_for_function(
|
| 24 |
-
"""() => {
|
| 25 |
-
const textarea = document.querySelector('#project_name textarea');
|
| 26 |
-
return textarea && textarea.value && textarea.value.includes('Sample:');
|
| 27 |
-
}""",
|
| 28 |
-
timeout=30000
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
class TestSampleLoading:
|
| 33 |
-
"""Test loading sample scenarios via dropdown."""
|
| 34 |
-
|
| 35 |
-
def test_bar_dining_loads_correctly(self, page: Page):
|
| 36 |
-
"""Verify Bar & Dining sample loads all data."""
|
| 37 |
-
select_sample(page, "Bar & Dining Area")
|
| 38 |
-
|
| 39 |
-
# Verify Tab 1 populated
|
| 40 |
-
expect(page.locator("#project_name textarea")).to_have_value(
|
| 41 |
-
"Sample: Bar & Dining Fire Assessment"
|
| 42 |
-
)
|
| 43 |
-
expect(page.locator("#city textarea")).to_have_value("Springfield")
|
| 44 |
-
|
| 45 |
-
def test_bar_area_loads_correctly(self, page: Page):
|
| 46 |
-
"""Verify Bar Area sample loads all data."""
|
| 47 |
-
select_sample(page, "Bar Area")
|
| 48 |
-
|
| 49 |
-
expect(page.locator("#project_name textarea")).to_have_value(
|
| 50 |
-
"Sample: Bar Area Fire Assessment"
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
def test_kitchen_loads_6_images(self, page: Page):
|
| 54 |
-
"""Verify Kitchen sample loads 6 images."""
|
| 55 |
-
select_sample(page, "Kitchen")
|
| 56 |
-
|
| 57 |
-
# Navigate to Images tab
|
| 58 |
-
page.locator("#tab-images-button").click()
|
| 59 |
-
|
| 60 |
-
# Wait for gallery to populate - may take time for images to render
|
| 61 |
-
page.wait_for_function(
|
| 62 |
-
"""() => {
|
| 63 |
-
const gallery = document.querySelector('#images_gallery');
|
| 64 |
-
const images = gallery ? gallery.querySelectorAll('img') : [];
|
| 65 |
-
return images.length > 0;
|
| 66 |
-
}""",
|
| 67 |
-
timeout=10000
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
# Verify image count in gallery
|
| 71 |
-
gallery = page.locator("#images_gallery")
|
| 72 |
-
images = gallery.locator("img")
|
| 73 |
-
expect(images).to_have_count(6)
|
| 74 |
-
|
| 75 |
-
def test_factory_loads_1_image(self, page: Page):
|
| 76 |
-
"""Verify Factory sample loads 1 image."""
|
| 77 |
-
select_sample(page, "Factory Area")
|
| 78 |
-
|
| 79 |
-
# Navigate to Images tab
|
| 80 |
-
page.locator("#tab-images-button").click()
|
| 81 |
-
page.wait_for_timeout(500)
|
| 82 |
-
|
| 83 |
-
# Verify image count
|
| 84 |
-
gallery = page.locator("#images_gallery")
|
| 85 |
-
images = gallery.locator("img")
|
| 86 |
-
expect(images).to_have_count(1)
|
| 87 |
-
|
| 88 |
-
def test_factory_operational_facility(self, page: Page):
|
| 89 |
-
"""Verify Factory sample has operational classification."""
|
| 90 |
-
select_sample(page, "Factory Area")
|
| 91 |
-
|
| 92 |
-
# Check facility classification shows Operational
|
| 93 |
-
expect(page.locator("#facility_classification")).to_contain_text("Operational")
|
| 94 |
-
|
| 95 |
-
def test_sample_dropdown_resets_after_selection(self, page: Page):
|
| 96 |
-
"""Verify dropdown resets to placeholder after loading sample."""
|
| 97 |
-
select_sample(page, "Bar & Dining Area")
|
| 98 |
-
|
| 99 |
-
# Dropdown should reset to placeholder text
|
| 100 |
-
dropdown_input = page.locator("#sample_dropdown input[role='listbox']")
|
| 101 |
-
expect(dropdown_input).to_have_value("Select a sample scenario...")
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
class TestSampleRoomData:
|
| 105 |
-
"""Test that sample rooms load correct data."""
|
| 106 |
-
|
| 107 |
-
def test_bar_dining_room_dimensions(self, page: Page):
|
| 108 |
-
"""Verify Bar & Dining room has correct dimensions."""
|
| 109 |
-
select_sample(page, "Bar & Dining Area")
|
| 110 |
-
|
| 111 |
-
# Navigate to Rooms tab
|
| 112 |
-
page.locator("#tab-rooms-button").click()
|
| 113 |
-
page.wait_for_timeout(500)
|
| 114 |
-
|
| 115 |
-
# Verify room table shows the room
|
| 116 |
-
expect(page.locator("#rooms_table")).to_contain_text("Bar & Dining Area")
|
| 117 |
-
expect(page.locator("#rooms_table")).to_contain_text("40") # Length
|
| 118 |
-
expect(page.locator("#rooms_table")).to_contain_text("30") # Width
|
| 119 |
-
|
| 120 |
-
def test_kitchen_room_dimensions(self, page: Page):
|
| 121 |
-
"""Verify Kitchen room has correct dimensions."""
|
| 122 |
-
select_sample(page, "Kitchen")
|
| 123 |
-
|
| 124 |
-
# Navigate to Rooms tab
|
| 125 |
-
page.locator("#tab-rooms-button").click()
|
| 126 |
-
page.wait_for_timeout(500)
|
| 127 |
-
|
| 128 |
-
# Verify room name
|
| 129 |
-
expect(page.locator("#rooms_table")).to_contain_text("Commercial Kitchen")
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
class TestSampleObservations:
|
| 133 |
-
"""Test that sample observations load correctly."""
|
| 134 |
-
|
| 135 |
-
def test_bar_dining_observations(self, page: Page):
|
| 136 |
-
"""Verify Bar & Dining sample loads observations."""
|
| 137 |
-
select_sample(page, "Bar & Dining Area")
|
| 138 |
-
|
| 139 |
-
# Navigate to Observations tab
|
| 140 |
-
page.locator("#tab-observations-button").click()
|
| 141 |
-
page.wait_for_timeout(500)
|
| 142 |
-
|
| 143 |
-
# Verify smoke odor checkbox is checked
|
| 144 |
-
smoke_checkbox = page.locator("#smoke_odor input[type='checkbox']")
|
| 145 |
-
expect(smoke_checkbox).to_be_checked()
|
| 146 |
-
|
| 147 |
-
# Verify odor intensity shows Strong
|
| 148 |
-
expect(page.locator("#odor_intensity")).to_contain_text("Strong")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_e2e_workflow.py
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 1 |
-
"""E2E tests for complete assessment workflow."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from playwright.sync_api import Page, expect
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def select_sample(page: Page, sample_text: str):
|
| 8 |
-
"""Helper to select a sample from the dropdown."""
|
| 9 |
-
page.locator("#sample_dropdown input[role='listbox']").click()
|
| 10 |
-
page.wait_for_timeout(300)
|
| 11 |
-
page.locator(f"[role='option']:has-text('{sample_text}')").click()
|
| 12 |
-
# Wait for form to be populated - check that project_name has a value
|
| 13 |
-
# (status gets cleared when dropdown resets, so we can't rely on it)
|
| 14 |
-
page.wait_for_function(
|
| 15 |
-
"""() => {
|
| 16 |
-
const textarea = document.querySelector('#project_name textarea');
|
| 17 |
-
return textarea && textarea.value && textarea.value.includes('Sample:');
|
| 18 |
-
}""",
|
| 19 |
-
timeout=30000
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
class TestFullWorkflow:
|
| 24 |
-
"""Test complete assessment generation workflow."""
|
| 25 |
-
|
| 26 |
-
def test_generate_with_sample(self, page: Page):
|
| 27 |
-
"""Test full generation workflow with sample data."""
|
| 28 |
-
select_sample(page, "Bar & Dining Area")
|
| 29 |
-
|
| 30 |
-
# Navigate to Results tab
|
| 31 |
-
page.locator("#tab-results-button").click()
|
| 32 |
-
page.wait_for_timeout(500)
|
| 33 |
-
|
| 34 |
-
# Check preflight shows ready
|
| 35 |
-
expect(page.locator("#preflight_status")).to_contain_text("Ready to Generate")
|
| 36 |
-
|
| 37 |
-
# Click generate
|
| 38 |
-
page.locator("#generate_btn").click()
|
| 39 |
-
|
| 40 |
-
# Wait for generation - check for "Complete" in the status textbox input
|
| 41 |
-
# The processing_status is a Textbox, so we need to check the input value
|
| 42 |
-
page.wait_for_function(
|
| 43 |
-
"""() => {
|
| 44 |
-
const input = document.querySelector('#processing_status input, #processing_status textarea');
|
| 45 |
-
return input && input.value && input.value.includes('Complete');
|
| 46 |
-
}""",
|
| 47 |
-
timeout=120000
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
# Verify outputs are visible
|
| 51 |
-
expect(page.locator("#sow_output")).to_be_visible()
|
| 52 |
-
expect(page.locator("#download_md")).to_be_visible()
|
| 53 |
-
|
| 54 |
-
def test_preflight_check_incomplete_session(self, page: Page):
|
| 55 |
-
"""Test preflight shows errors for incomplete session."""
|
| 56 |
-
# Don't load sample - go directly to Results
|
| 57 |
-
page.locator("#tab-results-button").click()
|
| 58 |
-
page.wait_for_timeout(500)
|
| 59 |
-
|
| 60 |
-
# Should show cannot generate message
|
| 61 |
-
expect(page.locator("#preflight_status")).to_contain_text("Cannot Generate")
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
class TestTabNavigation:
|
| 65 |
-
"""Test tab navigation and validation."""
|
| 66 |
-
|
| 67 |
-
def test_click_tab_navigation(self, page: Page):
|
| 68 |
-
"""Test clicking tab buttons navigates correctly."""
|
| 69 |
-
# Start on Tab 1 (Project)
|
| 70 |
-
expect(page.locator("#project_name")).to_be_visible()
|
| 71 |
-
|
| 72 |
-
# Click Tab 2 (Rooms)
|
| 73 |
-
page.locator("#tab-rooms-button").click()
|
| 74 |
-
page.wait_for_timeout(300)
|
| 75 |
-
expect(page.locator("#room_name")).to_be_visible()
|
| 76 |
-
|
| 77 |
-
# Click Tab 3 (Images)
|
| 78 |
-
page.locator("#tab-images-button").click()
|
| 79 |
-
page.wait_for_timeout(300)
|
| 80 |
-
expect(page.locator("#image_upload")).to_be_visible()
|
| 81 |
-
|
| 82 |
-
# Click Tab 4 (Observations)
|
| 83 |
-
page.locator("#tab-observations-button").click()
|
| 84 |
-
page.wait_for_timeout(300)
|
| 85 |
-
expect(page.locator("#smoke_odor")).to_be_visible()
|
| 86 |
-
|
| 87 |
-
# Click Tab 5 (Results)
|
| 88 |
-
page.locator("#tab-results-button").click()
|
| 89 |
-
page.wait_for_timeout(300)
|
| 90 |
-
expect(page.locator("#generate_btn")).to_be_visible()
|
| 91 |
-
|
| 92 |
-
def test_keyboard_shortcuts(self, page: Page):
|
| 93 |
-
"""Test Ctrl+1-5 keyboard shortcuts."""
|
| 94 |
-
# Start on Tab 1
|
| 95 |
-
expect(page.locator("#project_name")).to_be_visible()
|
| 96 |
-
|
| 97 |
-
# Ctrl+3 -> Images tab
|
| 98 |
-
page.keyboard.press("Control+3")
|
| 99 |
-
page.wait_for_timeout(300)
|
| 100 |
-
expect(page.locator("#image_upload")).to_be_visible()
|
| 101 |
-
|
| 102 |
-
# Ctrl+5 -> Results tab
|
| 103 |
-
page.keyboard.press("Control+5")
|
| 104 |
-
page.wait_for_timeout(300)
|
| 105 |
-
expect(page.locator("#generate_btn")).to_be_visible()
|
| 106 |
-
|
| 107 |
-
# Ctrl+1 -> Back to Project
|
| 108 |
-
page.keyboard.press("Control+1")
|
| 109 |
-
page.wait_for_timeout(300)
|
| 110 |
-
expect(page.locator("#project_name")).to_be_visible()
|
| 111 |
-
|
| 112 |
-
def test_tab1_validation_prevents_navigation(self, page: Page):
|
| 113 |
-
"""Test that incomplete Tab 1 shows validation errors."""
|
| 114 |
-
# Try to validate empty Tab 1
|
| 115 |
-
page.get_by_role("button", name="Validate & Continue to Rooms").click()
|
| 116 |
-
page.wait_for_timeout(500)
|
| 117 |
-
|
| 118 |
-
# Should show validation error message with required field errors
|
| 119 |
-
validation = page.locator("#tab1_validation")
|
| 120 |
-
expect(validation).to_contain_text("Please fix the following")
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
class TestBackNavigation:
|
| 124 |
-
"""Test back button navigation."""
|
| 125 |
-
|
| 126 |
-
def test_back_from_rooms_to_project(self, page: Page):
|
| 127 |
-
"""Test back button from Rooms tab."""
|
| 128 |
-
# Go to Rooms tab
|
| 129 |
-
page.locator("#tab-rooms-button").click()
|
| 130 |
-
page.wait_for_timeout(300)
|
| 131 |
-
|
| 132 |
-
# Click back
|
| 133 |
-
page.get_by_role("button", name="Back to Project").click()
|
| 134 |
-
page.wait_for_timeout(300)
|
| 135 |
-
|
| 136 |
-
# Should be on Project tab
|
| 137 |
-
expect(page.locator("#project_name")).to_be_visible()
|
| 138 |
-
|
| 139 |
-
def test_back_from_images_to_rooms(self, page: Page):
|
| 140 |
-
"""Test back button from Images tab."""
|
| 141 |
-
# Go to Images tab
|
| 142 |
-
page.locator("#tab-images-button").click()
|
| 143 |
-
page.wait_for_timeout(300)
|
| 144 |
-
|
| 145 |
-
# Click back
|
| 146 |
-
page.get_by_role("button", name="Back to Rooms").click()
|
| 147 |
-
page.wait_for_timeout(300)
|
| 148 |
-
|
| 149 |
-
# Should be on Rooms tab
|
| 150 |
-
expect(page.locator("#room_name")).to_be_visible()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_pdf_generator.py
DELETED
|
@@ -1,246 +0,0 @@
|
|
| 1 |
-
"""Tests for PDF generation module."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
import tempfile
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
|
| 7 |
-
from pipeline.pdf_generator import PDFGenerator, PDFResult, generate_sow_pdf, SOW_CSS
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class TestPDFGenerator:
|
| 11 |
-
"""Test PDF generator functionality."""
|
| 12 |
-
|
| 13 |
-
@pytest.fixture
|
| 14 |
-
def generator(self):
|
| 15 |
-
"""Create PDF generator instance."""
|
| 16 |
-
return PDFGenerator()
|
| 17 |
-
|
| 18 |
-
@pytest.fixture
|
| 19 |
-
def sample_markdown(self):
|
| 20 |
-
"""Sample markdown for testing."""
|
| 21 |
-
return """# Test Document
|
| 22 |
-
|
| 23 |
-
## Section One
|
| 24 |
-
|
| 25 |
-
This is a test paragraph with **bold** and *italic* text.
|
| 26 |
-
|
| 27 |
-
| Column A | Column B |
|
| 28 |
-
|----------|----------|
|
| 29 |
-
| Value 1 | Value 2 |
|
| 30 |
-
| Value 3 | Value 4 |
|
| 31 |
-
|
| 32 |
-
## Section Two
|
| 33 |
-
|
| 34 |
-
- Bullet point one
|
| 35 |
-
- Bullet point two
|
| 36 |
-
- Bullet point three
|
| 37 |
-
|
| 38 |
-
---
|
| 39 |
-
|
| 40 |
-
*Generated by test*
|
| 41 |
-
"""
|
| 42 |
-
|
| 43 |
-
def test_weasyprint_available(self, generator):
|
| 44 |
-
"""Test that WeasyPrint is detected as available."""
|
| 45 |
-
assert generator.weasyprint_available is True
|
| 46 |
-
|
| 47 |
-
def test_markdown_to_html(self, generator, sample_markdown):
|
| 48 |
-
"""Test markdown to HTML conversion."""
|
| 49 |
-
html = generator.markdown_to_html(sample_markdown)
|
| 50 |
-
|
| 51 |
-
assert "<!DOCTYPE html>" in html
|
| 52 |
-
assert "<html>" in html
|
| 53 |
-
assert "<style>" in html
|
| 54 |
-
# Note: markdown library adds id attribute to headers (from TOC extension)
|
| 55 |
-
assert "<h1" in html and "Test Document</h1>" in html
|
| 56 |
-
assert "<table>" in html
|
| 57 |
-
assert "<strong>bold</strong>" in html
|
| 58 |
-
|
| 59 |
-
def test_markdown_to_html_includes_css(self, generator, sample_markdown):
|
| 60 |
-
"""Test that HTML includes CSS styling."""
|
| 61 |
-
html = generator.markdown_to_html(sample_markdown)
|
| 62 |
-
|
| 63 |
-
# Check key CSS rules are included
|
| 64 |
-
assert "font-family" in html
|
| 65 |
-
assert "border-collapse" in html
|
| 66 |
-
assert "@page" in html
|
| 67 |
-
|
| 68 |
-
def test_generate_pdf_success(self, generator, sample_markdown):
|
| 69 |
-
"""Test successful PDF generation."""
|
| 70 |
-
result = generator.generate_pdf(sample_markdown)
|
| 71 |
-
|
| 72 |
-
assert isinstance(result, PDFResult)
|
| 73 |
-
assert result.success is True
|
| 74 |
-
assert result.pdf_path is not None
|
| 75 |
-
assert result.error_message is None
|
| 76 |
-
assert result.file_size_bytes > 0
|
| 77 |
-
|
| 78 |
-
# Verify file exists
|
| 79 |
-
pdf_path = Path(result.pdf_path)
|
| 80 |
-
assert pdf_path.exists()
|
| 81 |
-
assert pdf_path.suffix == ".pdf"
|
| 82 |
-
|
| 83 |
-
# Clean up
|
| 84 |
-
pdf_path.unlink()
|
| 85 |
-
|
| 86 |
-
def test_generate_pdf_with_custom_path(self, generator, sample_markdown):
|
| 87 |
-
"""Test PDF generation with custom output path."""
|
| 88 |
-
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
| 89 |
-
output_path = f.name
|
| 90 |
-
|
| 91 |
-
result = generator.generate_pdf(sample_markdown, output_path=output_path)
|
| 92 |
-
|
| 93 |
-
assert result.success is True
|
| 94 |
-
assert result.pdf_path == output_path
|
| 95 |
-
|
| 96 |
-
# Clean up
|
| 97 |
-
Path(output_path).unlink()
|
| 98 |
-
|
| 99 |
-
def test_generate_pdf_empty_content(self, generator):
|
| 100 |
-
"""Test PDF generation with empty content."""
|
| 101 |
-
result = generator.generate_pdf("")
|
| 102 |
-
|
| 103 |
-
# Should still succeed with empty content
|
| 104 |
-
assert result.success is True
|
| 105 |
-
assert result.pdf_path is not None
|
| 106 |
-
|
| 107 |
-
# Clean up
|
| 108 |
-
Path(result.pdf_path).unlink()
|
| 109 |
-
|
| 110 |
-
def test_generate_pdf_complex_tables(self, generator):
|
| 111 |
-
"""Test PDF with complex table content."""
|
| 112 |
-
markdown = """# Thresholds
|
| 113 |
-
|
| 114 |
-
| Metal | Non-Operational | Operational | Unit |
|
| 115 |
-
|-------|-----------------|-------------|------|
|
| 116 |
-
| Lead | 22 | 500 | µg/100cm² |
|
| 117 |
-
| Cadmium | 3.3 | 50 | µg/100cm² |
|
| 118 |
-
| Arsenic | 6.7 | 100 | µg/100cm² |
|
| 119 |
-
|
| 120 |
-
## Notes
|
| 121 |
-
|
| 122 |
-
Special characters: µ, °, ², ™
|
| 123 |
-
"""
|
| 124 |
-
result = generator.generate_pdf(markdown)
|
| 125 |
-
|
| 126 |
-
assert result.success is True
|
| 127 |
-
assert result.file_size_bytes > 0
|
| 128 |
-
|
| 129 |
-
# Clean up
|
| 130 |
-
Path(result.pdf_path).unlink()
|
| 131 |
-
|
| 132 |
-
def test_generate_html_fallback(self, generator, sample_markdown):
|
| 133 |
-
"""Test HTML generation as fallback."""
|
| 134 |
-
success, html_path, error = generator.generate_html(sample_markdown)
|
| 135 |
-
|
| 136 |
-
assert success is True
|
| 137 |
-
assert html_path is not None
|
| 138 |
-
assert error is None
|
| 139 |
-
|
| 140 |
-
# Verify file exists and contains HTML
|
| 141 |
-
html_path = Path(html_path)
|
| 142 |
-
assert html_path.exists()
|
| 143 |
-
content = html_path.read_text()
|
| 144 |
-
assert "<html>" in content
|
| 145 |
-
|
| 146 |
-
# Clean up
|
| 147 |
-
html_path.unlink()
|
| 148 |
-
|
| 149 |
-
def test_custom_css(self):
|
| 150 |
-
"""Test PDF generator with custom CSS."""
|
| 151 |
-
custom_css = """
|
| 152 |
-
body { font-family: monospace; }
|
| 153 |
-
h1 { color: red; }
|
| 154 |
-
"""
|
| 155 |
-
generator = PDFGenerator(custom_css=custom_css)
|
| 156 |
-
|
| 157 |
-
html = generator.markdown_to_html("# Test")
|
| 158 |
-
assert "font-family: monospace" in html
|
| 159 |
-
assert "color: red" in html
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
class TestGenerateSowPdf:
|
| 163 |
-
"""Test the convenience function."""
|
| 164 |
-
|
| 165 |
-
def test_generate_sow_pdf(self):
|
| 166 |
-
"""Test generate_sow_pdf convenience function."""
|
| 167 |
-
markdown = """# Scope of Work
|
| 168 |
-
|
| 169 |
-
## Project: Test Fire
|
| 170 |
-
|
| 171 |
-
| Field | Value |
|
| 172 |
-
|-------|-------|
|
| 173 |
-
| Client | ACME Corp |
|
| 174 |
-
| Date | 2024-01-15 |
|
| 175 |
-
|
| 176 |
-
## Recommendations
|
| 177 |
-
|
| 178 |
-
- Clean all surfaces
|
| 179 |
-
- HEPA vacuum required
|
| 180 |
-
"""
|
| 181 |
-
result = generate_sow_pdf(
|
| 182 |
-
markdown_content=markdown,
|
| 183 |
-
project_name="Test Fire",
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
assert result.success is True
|
| 187 |
-
assert result.pdf_path is not None
|
| 188 |
-
|
| 189 |
-
# Clean up
|
| 190 |
-
Path(result.pdf_path).unlink()
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
class TestSOWCSS:
|
| 194 |
-
"""Test CSS styling constants."""
|
| 195 |
-
|
| 196 |
-
def test_sow_css_exists(self):
|
| 197 |
-
"""Test that SOW_CSS is defined."""
|
| 198 |
-
assert SOW_CSS is not None
|
| 199 |
-
assert len(SOW_CSS) > 0
|
| 200 |
-
|
| 201 |
-
def test_sow_css_has_page_settings(self):
|
| 202 |
-
"""Test that CSS includes page settings."""
|
| 203 |
-
assert "@page" in SOW_CSS
|
| 204 |
-
assert "margin" in SOW_CSS
|
| 205 |
-
|
| 206 |
-
def test_sow_css_has_table_styling(self):
|
| 207 |
-
"""Test that CSS includes table styling."""
|
| 208 |
-
assert "table" in SOW_CSS
|
| 209 |
-
assert "border-collapse" in SOW_CSS
|
| 210 |
-
assert "th" in SOW_CSS
|
| 211 |
-
assert "td" in SOW_CSS
|
| 212 |
-
|
| 213 |
-
def test_sow_css_has_header_styling(self):
|
| 214 |
-
"""Test that CSS includes header styling."""
|
| 215 |
-
assert "h1" in SOW_CSS
|
| 216 |
-
assert "h2" in SOW_CSS
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
class TestPDFResultDataclass:
|
| 220 |
-
"""Test PDFResult dataclass."""
|
| 221 |
-
|
| 222 |
-
def test_pdf_result_success(self):
|
| 223 |
-
"""Test PDFResult with success."""
|
| 224 |
-
result = PDFResult(
|
| 225 |
-
success=True,
|
| 226 |
-
pdf_path="/tmp/test.pdf",
|
| 227 |
-
file_size_bytes=1000,
|
| 228 |
-
)
|
| 229 |
-
|
| 230 |
-
assert result.success is True
|
| 231 |
-
assert result.pdf_path == "/tmp/test.pdf"
|
| 232 |
-
assert result.error_message is None
|
| 233 |
-
assert result.file_size_bytes == 1000
|
| 234 |
-
|
| 235 |
-
def test_pdf_result_failure(self):
|
| 236 |
-
"""Test PDFResult with failure."""
|
| 237 |
-
result = PDFResult(
|
| 238 |
-
success=False,
|
| 239 |
-
pdf_path=None,
|
| 240 |
-
error_message="Something went wrong",
|
| 241 |
-
)
|
| 242 |
-
|
| 243 |
-
assert result.success is False
|
| 244 |
-
assert result.pdf_path is None
|
| 245 |
-
assert result.error_message == "Something went wrong"
|
| 246 |
-
assert result.file_size_bytes == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_pipeline.py
DELETED
|
@@ -1,525 +0,0 @@
|
|
| 1 |
-
"""Tests for FDAM Pipeline components."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from PIL import Image
|
| 5 |
-
import io
|
| 6 |
-
|
| 7 |
-
from pipeline.calculations import (
|
| 8 |
-
FDAMCalculator,
|
| 9 |
-
AirFiltrationResult,
|
| 10 |
-
SampleDensityResult,
|
| 11 |
-
RegulatoryFlags,
|
| 12 |
-
MetalsThresholds,
|
| 13 |
-
METALS_THRESHOLDS,
|
| 14 |
-
PARTICULATE_THRESHOLDS,
|
| 15 |
-
)
|
| 16 |
-
from pipeline.dispositions import (
|
| 17 |
-
DispositionEngine,
|
| 18 |
-
DispositionResult,
|
| 19 |
-
SurfaceDisposition,
|
| 20 |
-
DISPOSITION_MATRIX,
|
| 21 |
-
CLEANING_PROTOCOLS,
|
| 22 |
-
)
|
| 23 |
-
from pipeline.generator import DocumentGenerator, GeneratedDocument
|
| 24 |
-
from pipeline.main import FDAMPipeline, PipelineResult, PipelineProgress
|
| 25 |
-
|
| 26 |
-
from ui.state import SessionState, RoomFormData, ImageFormData
|
| 27 |
-
from ui.components import image_store
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class TestFDAMCalculator:
|
| 31 |
-
"""Test FDAM calculations."""
|
| 32 |
-
|
| 33 |
-
@pytest.fixture
|
| 34 |
-
def calculator(self):
|
| 35 |
-
return FDAMCalculator()
|
| 36 |
-
|
| 37 |
-
def test_air_filtration_basic(self, calculator):
|
| 38 |
-
"""Test basic air filtration calculation."""
|
| 39 |
-
result = calculator.calculate_air_filtration(
|
| 40 |
-
total_area_sf=10000,
|
| 41 |
-
avg_ceiling_height_ft=10,
|
| 42 |
-
)
|
| 43 |
-
|
| 44 |
-
assert isinstance(result, AirFiltrationResult)
|
| 45 |
-
assert result.total_volume_cf == 100000
|
| 46 |
-
assert result.required_ach == 4
|
| 47 |
-
assert result.unit_cfm == 2000
|
| 48 |
-
# (100000 * 4) / (2000 * 60) = 3.33 -> 4 units
|
| 49 |
-
assert result.units_required == 4
|
| 50 |
-
|
| 51 |
-
def test_air_filtration_large_space(self, calculator):
|
| 52 |
-
"""Test air filtration for large space."""
|
| 53 |
-
result = calculator.calculate_air_filtration(
|
| 54 |
-
total_area_sf=50000,
|
| 55 |
-
avg_ceiling_height_ft=30,
|
| 56 |
-
)
|
| 57 |
-
|
| 58 |
-
# 1,500,000 CF * 4 ACH / (2000 * 60) = 50 units
|
| 59 |
-
assert result.units_required == 50
|
| 60 |
-
assert result.total_volume_cf == 1500000
|
| 61 |
-
|
| 62 |
-
def test_air_filtration_minimum_one_unit(self, calculator):
|
| 63 |
-
"""Test minimum 1 unit is required."""
|
| 64 |
-
result = calculator.calculate_air_filtration(
|
| 65 |
-
total_area_sf=100,
|
| 66 |
-
avg_ceiling_height_ft=8,
|
| 67 |
-
)
|
| 68 |
-
|
| 69 |
-
assert result.units_required >= 1
|
| 70 |
-
|
| 71 |
-
def test_sample_density_small_area(self, calculator):
|
| 72 |
-
"""Test sample density for small area."""
|
| 73 |
-
result = calculator.calculate_sample_density(
|
| 74 |
-
total_area_sf=3000,
|
| 75 |
-
surface_types_count=3,
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
assert isinstance(result, SampleDensityResult)
|
| 79 |
-
assert result.tape_lifts_min == 9 # 3 * 3
|
| 80 |
-
assert result.tape_lifts_max == 15 # 5 * 3
|
| 81 |
-
|
| 82 |
-
def test_sample_density_medium_area(self, calculator):
|
| 83 |
-
"""Test sample density for medium area."""
|
| 84 |
-
result = calculator.calculate_sample_density(
|
| 85 |
-
total_area_sf=15000,
|
| 86 |
-
surface_types_count=3,
|
| 87 |
-
)
|
| 88 |
-
|
| 89 |
-
assert result.tape_lifts_min == 15 # 5 * 3
|
| 90 |
-
assert result.tape_lifts_max == 30 # 10 * 3
|
| 91 |
-
|
| 92 |
-
def test_sample_density_ceiling_deck(self, calculator):
|
| 93 |
-
"""Test ceiling deck enhanced sampling."""
|
| 94 |
-
result = calculator.calculate_sample_density(
|
| 95 |
-
total_area_sf=10000,
|
| 96 |
-
has_ceiling_deck=True,
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
# 1 per 2,500 SF = 4 samples
|
| 100 |
-
assert result.ceiling_deck_samples == 4
|
| 101 |
-
|
| 102 |
-
def test_regulatory_flags_pre_1980(self, calculator):
|
| 103 |
-
"""Test regulatory flags for pre-1980 construction."""
|
| 104 |
-
flags = calculator.get_regulatory_flags(
|
| 105 |
-
construction_era="pre-1980",
|
| 106 |
-
facility_classification="non-operational",
|
| 107 |
-
)
|
| 108 |
-
|
| 109 |
-
assert isinstance(flags, RegulatoryFlags)
|
| 110 |
-
assert flags.lbp_survey_required is True
|
| 111 |
-
assert flags.acm_survey_required is True
|
| 112 |
-
assert flags.acm_survey_recommended is False
|
| 113 |
-
|
| 114 |
-
def test_regulatory_flags_1980_2000(self, calculator):
|
| 115 |
-
"""Test regulatory flags for 1980-2000 construction."""
|
| 116 |
-
flags = calculator.get_regulatory_flags(
|
| 117 |
-
construction_era="1980-2000",
|
| 118 |
-
facility_classification="operational",
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
assert flags.lbp_survey_required is False
|
| 122 |
-
assert flags.acm_survey_required is False
|
| 123 |
-
assert flags.acm_survey_recommended is True
|
| 124 |
-
|
| 125 |
-
def test_regulatory_flags_childcare(self, calculator):
|
| 126 |
-
"""Test regulatory flags for public/childcare."""
|
| 127 |
-
flags = calculator.get_regulatory_flags(
|
| 128 |
-
construction_era="post-2000",
|
| 129 |
-
facility_classification="public-childcare",
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
assert flags.enhanced_childcare_thresholds is True
|
| 133 |
-
|
| 134 |
-
def test_metals_thresholds_non_operational(self, calculator):
|
| 135 |
-
"""Test metals thresholds for non-operational facility."""
|
| 136 |
-
thresholds = calculator.get_metals_thresholds("non-operational")
|
| 137 |
-
|
| 138 |
-
assert isinstance(thresholds, MetalsThresholds)
|
| 139 |
-
assert thresholds.lead_ug_100cm2 == 22.0
|
| 140 |
-
assert thresholds.cadmium_ug_100cm2 == 3.3
|
| 141 |
-
assert thresholds.arsenic_ug_100cm2 == 6.7
|
| 142 |
-
|
| 143 |
-
def test_metals_thresholds_operational(self, calculator):
|
| 144 |
-
"""Test metals thresholds for operational facility."""
|
| 145 |
-
thresholds = calculator.get_metals_thresholds("operational")
|
| 146 |
-
|
| 147 |
-
assert thresholds.lead_ug_100cm2 == 500.0
|
| 148 |
-
assert thresholds.cadmium_ug_100cm2 == 50.0
|
| 149 |
-
|
| 150 |
-
def test_metals_thresholds_childcare(self, calculator):
|
| 151 |
-
"""Test metals thresholds for childcare facility."""
|
| 152 |
-
thresholds = calculator.get_metals_thresholds("public-childcare")
|
| 153 |
-
|
| 154 |
-
# EPA/HUD October 2024 for floors
|
| 155 |
-
assert thresholds.lead_ug_100cm2 == 4.3
|
| 156 |
-
|
| 157 |
-
def test_particulate_thresholds_exist(self):
|
| 158 |
-
"""Test particulate thresholds are defined."""
|
| 159 |
-
assert "ash_char" in PARTICULATE_THRESHOLDS
|
| 160 |
-
assert "aciniform_soot" in PARTICULATE_THRESHOLDS
|
| 161 |
-
assert PARTICULATE_THRESHOLDS["ash_char"]["clearance"] == 150
|
| 162 |
-
assert PARTICULATE_THRESHOLDS["aciniform_soot"]["clearance"] == 500
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
class TestDispositionEngine:
|
| 166 |
-
"""Test disposition determination."""
|
| 167 |
-
|
| 168 |
-
@pytest.fixture
|
| 169 |
-
def engine(self):
|
| 170 |
-
return DispositionEngine()
|
| 171 |
-
|
| 172 |
-
def test_disposition_background(self, engine):
|
| 173 |
-
"""Test disposition for background condition."""
|
| 174 |
-
result = engine.determine_disposition(
|
| 175 |
-
zone="far-field",
|
| 176 |
-
condition="background",
|
| 177 |
-
use_rag=False,
|
| 178 |
-
)
|
| 179 |
-
|
| 180 |
-
assert isinstance(result, DispositionResult)
|
| 181 |
-
assert result.disposition == "no-action"
|
| 182 |
-
assert result.confidence == 1.0
|
| 183 |
-
|
| 184 |
-
def test_disposition_structural_damage(self, engine):
|
| 185 |
-
"""Test disposition for structural damage."""
|
| 186 |
-
result = engine.determine_disposition(
|
| 187 |
-
zone="burn-zone",
|
| 188 |
-
condition="structural-damage",
|
| 189 |
-
use_rag=False,
|
| 190 |
-
)
|
| 191 |
-
|
| 192 |
-
assert result.disposition == "remove-repair"
|
| 193 |
-
assert result.confidence == 1.0
|
| 194 |
-
|
| 195 |
-
def test_disposition_far_field_light(self, engine):
|
| 196 |
-
"""Test disposition for far-field light condition."""
|
| 197 |
-
result = engine.determine_disposition(
|
| 198 |
-
zone="far-field",
|
| 199 |
-
condition="light",
|
| 200 |
-
use_rag=False,
|
| 201 |
-
)
|
| 202 |
-
|
| 203 |
-
assert result.disposition == "clean"
|
| 204 |
-
assert "standard" in result.protocol.lower()
|
| 205 |
-
|
| 206 |
-
def test_disposition_near_field_heavy(self, engine):
|
| 207 |
-
"""Test disposition for near-field heavy condition."""
|
| 208 |
-
result = engine.determine_disposition(
|
| 209 |
-
zone="near-field",
|
| 210 |
-
condition="heavy",
|
| 211 |
-
use_rag=False,
|
| 212 |
-
)
|
| 213 |
-
|
| 214 |
-
assert result.disposition == "clean"
|
| 215 |
-
assert "aggressive" in result.protocol.lower()
|
| 216 |
-
|
| 217 |
-
def test_cleaning_method_drywall(self, engine):
|
| 218 |
-
"""Test cleaning method for drywall."""
|
| 219 |
-
method = engine.get_cleaning_method(
|
| 220 |
-
surface_type="drywall",
|
| 221 |
-
condition="moderate",
|
| 222 |
-
use_rag=False,
|
| 223 |
-
)
|
| 224 |
-
|
| 225 |
-
assert "HEPA" in method["method"]
|
| 226 |
-
assert method["surface_type"] == "drywall"
|
| 227 |
-
|
| 228 |
-
def test_cleaning_method_concrete(self, engine):
|
| 229 |
-
"""Test cleaning method for concrete."""
|
| 230 |
-
method = engine.get_cleaning_method(
|
| 231 |
-
surface_type="concrete-floor",
|
| 232 |
-
condition="heavy",
|
| 233 |
-
use_rag=False,
|
| 234 |
-
)
|
| 235 |
-
|
| 236 |
-
assert "scrubber" in method["method"].lower()
|
| 237 |
-
assert "multiple passes" in method["method"].lower()
|
| 238 |
-
|
| 239 |
-
def test_disposition_matrix_completeness(self):
|
| 240 |
-
"""Test disposition matrix covers expected combinations."""
|
| 241 |
-
# Key combinations should be in matrix
|
| 242 |
-
assert ("far-field", "light") in DISPOSITION_MATRIX
|
| 243 |
-
assert ("near-field", "moderate") in DISPOSITION_MATRIX
|
| 244 |
-
assert ("burn-zone", "heavy") in DISPOSITION_MATRIX
|
| 245 |
-
assert ("any", "background") in DISPOSITION_MATRIX
|
| 246 |
-
assert ("any", "structural-damage") in DISPOSITION_MATRIX
|
| 247 |
-
|
| 248 |
-
def test_cleaning_protocols_exist(self):
|
| 249 |
-
"""Test cleaning protocols are defined."""
|
| 250 |
-
assert "standard" in CLEANING_PROTOCOLS
|
| 251 |
-
assert "full" in CLEANING_PROTOCOLS
|
| 252 |
-
assert "aggressive" in CLEANING_PROTOCOLS
|
| 253 |
-
|
| 254 |
-
for protocol in CLEANING_PROTOCOLS.values():
|
| 255 |
-
assert "name" in protocol
|
| 256 |
-
assert "steps" in protocol
|
| 257 |
-
assert len(protocol["steps"]) > 0
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
class TestDocumentGenerator:
|
| 261 |
-
"""Test document generation."""
|
| 262 |
-
|
| 263 |
-
@pytest.fixture
|
| 264 |
-
def generator(self):
|
| 265 |
-
return DocumentGenerator()
|
| 266 |
-
|
| 267 |
-
@pytest.fixture
|
| 268 |
-
def sample_session(self):
|
| 269 |
-
session = SessionState()
|
| 270 |
-
session.project.project_name = "Test Fire Project"
|
| 271 |
-
session.project.address = "123 Main St"
|
| 272 |
-
session.project.city = "Springfield"
|
| 273 |
-
session.project.state = "IL"
|
| 274 |
-
session.project.zip_code = "62701"
|
| 275 |
-
session.project.client_name = "Test Client"
|
| 276 |
-
session.project.fire_date = "2024-01-01"
|
| 277 |
-
session.project.assessment_date = "2024-01-15"
|
| 278 |
-
session.project.facility_classification = "non-operational"
|
| 279 |
-
session.project.construction_era = "pre-1980"
|
| 280 |
-
session.project.assessor_name = "John Doe"
|
| 281 |
-
session.project.assessor_credentials = "CIH"
|
| 282 |
-
|
| 283 |
-
session.rooms.append(
|
| 284 |
-
RoomFormData(
|
| 285 |
-
id="room-001",
|
| 286 |
-
name="Main Hall",
|
| 287 |
-
length_ft=50,
|
| 288 |
-
width_ft=30,
|
| 289 |
-
ceiling_height_ft=12,
|
| 290 |
-
)
|
| 291 |
-
)
|
| 292 |
-
return session
|
| 293 |
-
|
| 294 |
-
@pytest.fixture
|
| 295 |
-
def sample_calculations(self):
|
| 296 |
-
calc = FDAMCalculator()
|
| 297 |
-
return {
|
| 298 |
-
"total_area_sf": 1500,
|
| 299 |
-
"total_volume_cf": 18000,
|
| 300 |
-
"avg_ceiling_height_ft": 12,
|
| 301 |
-
"air_filtration": calc.calculate_air_filtration(1500, 12),
|
| 302 |
-
"sample_density": calc.calculate_sample_density(1500),
|
| 303 |
-
"regulatory_flags": calc.get_regulatory_flags("pre-1980", "non-operational"),
|
| 304 |
-
"metals_thresholds": calc.get_metals_thresholds("non-operational"),
|
| 305 |
-
"particulate_thresholds": PARTICULATE_THRESHOLDS,
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
def test_generate_sow_basic(self, generator, sample_session, sample_calculations):
|
| 309 |
-
"""Test basic SOW generation."""
|
| 310 |
-
doc = generator.generate_sow(
|
| 311 |
-
session=sample_session,
|
| 312 |
-
vision_results={},
|
| 313 |
-
surface_dispositions=[],
|
| 314 |
-
calculations=sample_calculations,
|
| 315 |
-
)
|
| 316 |
-
|
| 317 |
-
assert isinstance(doc, GeneratedDocument)
|
| 318 |
-
assert "Test Fire Project" in doc.markdown
|
| 319 |
-
assert "Cleaning Specification" in doc.markdown
|
| 320 |
-
assert doc.word_count > 0
|
| 321 |
-
|
| 322 |
-
def test_generate_sow_sections(self, generator, sample_session, sample_calculations):
|
| 323 |
-
"""Test SOW contains required sections."""
|
| 324 |
-
doc = generator.generate_sow(
|
| 325 |
-
session=sample_session,
|
| 326 |
-
vision_results={},
|
| 327 |
-
surface_dispositions=[],
|
| 328 |
-
calculations=sample_calculations,
|
| 329 |
-
)
|
| 330 |
-
|
| 331 |
-
# Check for key sections
|
| 332 |
-
assert "## Project Information" in doc.markdown
|
| 333 |
-
assert "## Scope Summary" in doc.markdown
|
| 334 |
-
assert "## Room Inventory" in doc.markdown
|
| 335 |
-
assert "## Air Filtration Requirements" in doc.markdown
|
| 336 |
-
assert "## Regulatory Requirements" in doc.markdown
|
| 337 |
-
assert "## Clearance Thresholds" in doc.markdown
|
| 338 |
-
|
| 339 |
-
def test_generate_sow_with_dispositions(self, generator, sample_session, sample_calculations):
|
| 340 |
-
"""Test SOW generation with dispositions."""
|
| 341 |
-
dispositions = [
|
| 342 |
-
SurfaceDisposition(
|
| 343 |
-
surface_type="drywall",
|
| 344 |
-
room_name="Main Hall",
|
| 345 |
-
zone="near-field",
|
| 346 |
-
condition="moderate",
|
| 347 |
-
disposition="clean",
|
| 348 |
-
cleaning_method="HEPA vacuum → Wet wipe",
|
| 349 |
-
)
|
| 350 |
-
]
|
| 351 |
-
|
| 352 |
-
doc = generator.generate_sow(
|
| 353 |
-
session=sample_session,
|
| 354 |
-
vision_results={},
|
| 355 |
-
surface_dispositions=dispositions,
|
| 356 |
-
calculations=sample_calculations,
|
| 357 |
-
)
|
| 358 |
-
|
| 359 |
-
assert "drywall" in doc.markdown.lower()
|
| 360 |
-
assert "CLEAN" in doc.markdown
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
class TestFDAMPipeline:
|
| 364 |
-
"""Test full pipeline execution."""
|
| 365 |
-
|
| 366 |
-
@pytest.fixture
|
| 367 |
-
def pipeline(self):
|
| 368 |
-
return FDAMPipeline()
|
| 369 |
-
|
| 370 |
-
@pytest.fixture
|
| 371 |
-
def valid_session(self):
|
| 372 |
-
"""Create a valid session for pipeline testing."""
|
| 373 |
-
session = SessionState()
|
| 374 |
-
session.project.project_name = "Pipeline Test"
|
| 375 |
-
session.project.address = "456 Oak Ave"
|
| 376 |
-
session.project.city = "Chicago"
|
| 377 |
-
session.project.state = "IL"
|
| 378 |
-
session.project.zip_code = "60601"
|
| 379 |
-
session.project.client_name = "Test Corp"
|
| 380 |
-
session.project.fire_date = "2024-06-01"
|
| 381 |
-
session.project.assessment_date = "2024-06-15"
|
| 382 |
-
session.project.facility_classification = "operational"
|
| 383 |
-
session.project.construction_era = "post-2000"
|
| 384 |
-
session.project.assessor_name = "Jane Smith"
|
| 385 |
-
|
| 386 |
-
session.rooms.append(
|
| 387 |
-
RoomFormData(
|
| 388 |
-
id="room-001",
|
| 389 |
-
name="Office A",
|
| 390 |
-
length_ft=20,
|
| 391 |
-
width_ft=15,
|
| 392 |
-
ceiling_height_ft=10,
|
| 393 |
-
)
|
| 394 |
-
)
|
| 395 |
-
|
| 396 |
-
# Add image metadata
|
| 397 |
-
img_id = "test-img-001"
|
| 398 |
-
session.images.append(
|
| 399 |
-
ImageFormData(
|
| 400 |
-
id=img_id,
|
| 401 |
-
filename="test.jpg",
|
| 402 |
-
room_id="room-001",
|
| 403 |
-
)
|
| 404 |
-
)
|
| 405 |
-
|
| 406 |
-
# Store actual image bytes
|
| 407 |
-
test_image = Image.new("RGB", (100, 100), color="red")
|
| 408 |
-
img_bytes = io.BytesIO()
|
| 409 |
-
test_image.save(img_bytes, format="PNG")
|
| 410 |
-
image_store.store(img_id, img_bytes.getvalue())
|
| 411 |
-
|
| 412 |
-
yield session
|
| 413 |
-
|
| 414 |
-
# Cleanup
|
| 415 |
-
image_store.clear()
|
| 416 |
-
|
| 417 |
-
def test_pipeline_execute_success(self, pipeline, valid_session):
|
| 418 |
-
"""Test successful pipeline execution."""
|
| 419 |
-
progress_updates = []
|
| 420 |
-
|
| 421 |
-
def progress_callback(prog):
|
| 422 |
-
progress_updates.append(prog)
|
| 423 |
-
|
| 424 |
-
result = pipeline.execute(
|
| 425 |
-
session=valid_session,
|
| 426 |
-
progress_callback=progress_callback,
|
| 427 |
-
)
|
| 428 |
-
|
| 429 |
-
assert isinstance(result, PipelineResult)
|
| 430 |
-
assert result.success is True
|
| 431 |
-
assert result.document is not None
|
| 432 |
-
assert len(result.annotated_images) > 0
|
| 433 |
-
assert result.execution_time_seconds > 0
|
| 434 |
-
assert len(progress_updates) > 0
|
| 435 |
-
|
| 436 |
-
def test_pipeline_execute_missing_project_name(self, pipeline):
|
| 437 |
-
"""Test pipeline fails with missing project name."""
|
| 438 |
-
session = SessionState()
|
| 439 |
-
# No project name set
|
| 440 |
-
|
| 441 |
-
result = pipeline.execute(session=session)
|
| 442 |
-
|
| 443 |
-
assert result.success is False
|
| 444 |
-
assert len(result.errors) > 0
|
| 445 |
-
assert any("project" in e.lower() for e in result.errors)
|
| 446 |
-
|
| 447 |
-
def test_pipeline_execute_missing_images(self, pipeline):
|
| 448 |
-
"""Test pipeline fails with missing image bytes."""
|
| 449 |
-
session = SessionState()
|
| 450 |
-
session.project.project_name = "Test"
|
| 451 |
-
session.project.address = "123 Main"
|
| 452 |
-
session.project.city = "City"
|
| 453 |
-
session.project.state = "ST"
|
| 454 |
-
session.project.zip_code = "12345"
|
| 455 |
-
session.project.client_name = "Client"
|
| 456 |
-
session.project.fire_date = "2024-01-01"
|
| 457 |
-
session.project.assessment_date = "2024-01-02"
|
| 458 |
-
session.project.assessor_name = "Assessor"
|
| 459 |
-
|
| 460 |
-
session.rooms.append(
|
| 461 |
-
RoomFormData(id="r1", name="Room", length_ft=10, width_ft=10, ceiling_height_ft=10)
|
| 462 |
-
)
|
| 463 |
-
session.images.append(
|
| 464 |
-
ImageFormData(id="missing-img", filename="missing.jpg", room_id="r1")
|
| 465 |
-
)
|
| 466 |
-
# Don't store image bytes
|
| 467 |
-
|
| 468 |
-
result = pipeline.execute(session=session)
|
| 469 |
-
|
| 470 |
-
assert result.success is False
|
| 471 |
-
assert any("image" in e.lower() or "upload" in e.lower() for e in result.errors)
|
| 472 |
-
|
| 473 |
-
def test_pipeline_generates_stats(self, pipeline, valid_session):
|
| 474 |
-
"""Test pipeline generates stats dictionary."""
|
| 475 |
-
result = pipeline.execute(session=valid_session)
|
| 476 |
-
|
| 477 |
-
stats = pipeline.generate_stats_dict(result)
|
| 478 |
-
|
| 479 |
-
assert "project_name" in stats
|
| 480 |
-
assert "total_rooms" in stats
|
| 481 |
-
assert "air_scrubbers_required" in stats
|
| 482 |
-
assert "execution_time" in stats
|
| 483 |
-
|
| 484 |
-
def test_pipeline_progress_stages(self, pipeline, valid_session):
|
| 485 |
-
"""Test pipeline reports all 6 stages."""
|
| 486 |
-
stages_seen = set()
|
| 487 |
-
|
| 488 |
-
def progress_callback(prog):
|
| 489 |
-
stages_seen.add(prog.stage)
|
| 490 |
-
|
| 491 |
-
pipeline.execute(
|
| 492 |
-
session=valid_session,
|
| 493 |
-
progress_callback=progress_callback,
|
| 494 |
-
)
|
| 495 |
-
|
| 496 |
-
# Should see stages 1-6
|
| 497 |
-
assert len(stages_seen) >= 5 # At least most stages
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
class TestIntegration:
|
| 501 |
-
"""Integration tests for pipeline with RAG."""
|
| 502 |
-
|
| 503 |
-
def test_calculator_with_session(self):
|
| 504 |
-
"""Test calculator with real session data."""
|
| 505 |
-
session = SessionState()
|
| 506 |
-
session.project.facility_classification = "non-operational"
|
| 507 |
-
session.project.construction_era = "pre-1980"
|
| 508 |
-
session.rooms.append(
|
| 509 |
-
RoomFormData(
|
| 510 |
-
id="r1",
|
| 511 |
-
name="Room 1",
|
| 512 |
-
length_ft=100,
|
| 513 |
-
width_ft=50,
|
| 514 |
-
ceiling_height_ft=15,
|
| 515 |
-
)
|
| 516 |
-
)
|
| 517 |
-
|
| 518 |
-
calc = FDAMCalculator()
|
| 519 |
-
results = calc.calculate_from_session(session)
|
| 520 |
-
|
| 521 |
-
assert results["total_area_sf"] == 5000
|
| 522 |
-
assert results["total_volume_cf"] == 75000
|
| 523 |
-
assert results["air_filtration"].units_required > 0
|
| 524 |
-
assert results["regulatory_flags"].lbp_survey_required is True
|
| 525 |
-
assert results["metals_thresholds"].lead_ug_100cm2 == 22.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_rag.py
DELETED
|
@@ -1,536 +0,0 @@
|
|
| 1 |
-
"""Tests for RAG (Retrieval Augmented Generation) components."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
import tempfile
|
| 6 |
-
import shutil
|
| 7 |
-
|
| 8 |
-
from rag.chunker import SemanticChunker, Chunk, chunk_file
|
| 9 |
-
from rag.vectorstore import ChromaVectorStore, MockEmbeddingFunction
|
| 10 |
-
from rag.retriever import FDAMRetriever, MockReranker, RetrievalResult
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
class TestSemanticChunker:
|
| 14 |
-
"""Test semantic chunker with table preservation."""
|
| 15 |
-
|
| 16 |
-
def test_chunk_simple_document(self):
|
| 17 |
-
"""Test chunking a simple markdown document."""
|
| 18 |
-
text = """## Introduction
|
| 19 |
-
|
| 20 |
-
This is the introduction paragraph with some content.
|
| 21 |
-
|
| 22 |
-
## Section One
|
| 23 |
-
|
| 24 |
-
This section contains important information about the topic.
|
| 25 |
-
It has multiple sentences to form a proper paragraph.
|
| 26 |
-
|
| 27 |
-
## Section Two
|
| 28 |
-
|
| 29 |
-
Another section with different content here.
|
| 30 |
-
"""
|
| 31 |
-
chunker = SemanticChunker()
|
| 32 |
-
chunks = chunker.chunk_document(
|
| 33 |
-
text=text,
|
| 34 |
-
source="test.md",
|
| 35 |
-
category="methodology",
|
| 36 |
-
priority="primary",
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
assert len(chunks) >= 1
|
| 40 |
-
assert all(isinstance(c, Chunk) for c in chunks)
|
| 41 |
-
assert all(c.source == "test.md" for c in chunks)
|
| 42 |
-
assert all(c.category == "methodology" for c in chunks)
|
| 43 |
-
assert all(c.priority == "primary" for c in chunks)
|
| 44 |
-
|
| 45 |
-
def test_preserve_tables(self):
|
| 46 |
-
"""Test that tables are kept intact and not split."""
|
| 47 |
-
text = """## Thresholds
|
| 48 |
-
|
| 49 |
-
| Material | Threshold | Unit |
|
| 50 |
-
|----------|-----------|------|
|
| 51 |
-
| Lead | 22 | µg/100cm² |
|
| 52 |
-
| Cadmium | 3.3 | µg/100cm² |
|
| 53 |
-
| Arsenic | 6.7 | µg/100cm² |
|
| 54 |
-
|
| 55 |
-
## Next Section
|
| 56 |
-
|
| 57 |
-
Some content after the table.
|
| 58 |
-
"""
|
| 59 |
-
chunker = SemanticChunker()
|
| 60 |
-
chunks = chunker.chunk_document(
|
| 61 |
-
text=text,
|
| 62 |
-
source="thresholds.md",
|
| 63 |
-
category="thresholds",
|
| 64 |
-
priority="reference-threshold",
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
# Find the table chunk
|
| 68 |
-
table_chunks = [c for c in chunks if c.content_type == "table"]
|
| 69 |
-
assert len(table_chunks) >= 1
|
| 70 |
-
|
| 71 |
-
# Table should be complete
|
| 72 |
-
table_chunk = table_chunks[0]
|
| 73 |
-
assert "Lead" in table_chunk.text
|
| 74 |
-
assert "Cadmium" in table_chunk.text
|
| 75 |
-
assert "Arsenic" in table_chunk.text
|
| 76 |
-
assert "|" in table_chunk.text
|
| 77 |
-
|
| 78 |
-
def test_extract_keywords(self):
|
| 79 |
-
"""Test keyword extraction from text."""
|
| 80 |
-
text = """## Zone Classification
|
| 81 |
-
|
| 82 |
-
The burn zone shows heavy soot deposits and structural damage.
|
| 83 |
-
Lead contamination requires HEPA vacuum cleaning per OSHA standards.
|
| 84 |
-
"""
|
| 85 |
-
chunker = SemanticChunker()
|
| 86 |
-
chunks = chunker.chunk_document(
|
| 87 |
-
text=text,
|
| 88 |
-
source="zones.md",
|
| 89 |
-
category="methodology",
|
| 90 |
-
priority="primary",
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
# Should extract relevant keywords
|
| 94 |
-
all_keywords = []
|
| 95 |
-
for chunk in chunks:
|
| 96 |
-
all_keywords.extend(chunk.keywords)
|
| 97 |
-
|
| 98 |
-
# Check for expected domain keywords
|
| 99 |
-
keyword_set = set(all_keywords)
|
| 100 |
-
assert "burn zone" in keyword_set or "heavy" in keyword_set
|
| 101 |
-
assert "soot" in keyword_set or "structural damage" in keyword_set
|
| 102 |
-
|
| 103 |
-
def test_chunk_metadata(self):
|
| 104 |
-
"""Test chunk metadata conversion."""
|
| 105 |
-
chunk = Chunk(
|
| 106 |
-
id="test_001",
|
| 107 |
-
text="Test content",
|
| 108 |
-
source="test.md",
|
| 109 |
-
category="methodology",
|
| 110 |
-
section="## Section 1",
|
| 111 |
-
priority="primary",
|
| 112 |
-
content_type="narrative",
|
| 113 |
-
keywords=["lead", "soot"],
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
metadata = chunk.to_metadata()
|
| 117 |
-
|
| 118 |
-
assert metadata["source"] == "test.md"
|
| 119 |
-
assert metadata["category"] == "methodology"
|
| 120 |
-
assert metadata["priority"] == "primary"
|
| 121 |
-
assert metadata["content_type"] == "narrative"
|
| 122 |
-
assert "lead" in metadata["keywords"]
|
| 123 |
-
assert "soot" in metadata["keywords"]
|
| 124 |
-
|
| 125 |
-
def test_split_by_headers(self):
|
| 126 |
-
"""Test section splitting by markdown headers."""
|
| 127 |
-
text = """## Section One
|
| 128 |
-
|
| 129 |
-
Content one.
|
| 130 |
-
|
| 131 |
-
### Subsection A
|
| 132 |
-
|
| 133 |
-
Content A.
|
| 134 |
-
|
| 135 |
-
## Section Two
|
| 136 |
-
|
| 137 |
-
Content two.
|
| 138 |
-
"""
|
| 139 |
-
chunker = SemanticChunker()
|
| 140 |
-
sections = chunker._split_by_headers(text)
|
| 141 |
-
|
| 142 |
-
# Should have at least 3 sections (Introduction + 2 main + 1 sub)
|
| 143 |
-
assert len(sections) >= 2
|
| 144 |
-
headers = [s[0] for s in sections]
|
| 145 |
-
assert any("Section One" in h for h in headers)
|
| 146 |
-
assert any("Section Two" in h for h in headers)
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
class TestMockEmbeddingFunction:
|
| 150 |
-
"""Test mock embedding function."""
|
| 151 |
-
|
| 152 |
-
def test_embedding_dimension(self):
|
| 153 |
-
"""Test that embeddings have correct dimension."""
|
| 154 |
-
mock = MockEmbeddingFunction()
|
| 155 |
-
embeddings = mock(["test text"])
|
| 156 |
-
|
| 157 |
-
assert len(embeddings) == 1
|
| 158 |
-
assert len(embeddings[0]) == mock.EMBEDDING_DIM
|
| 159 |
-
|
| 160 |
-
def test_deterministic_embeddings(self):
|
| 161 |
-
"""Test that same text produces same embedding."""
|
| 162 |
-
mock = MockEmbeddingFunction()
|
| 163 |
-
text = "This is a test sentence."
|
| 164 |
-
|
| 165 |
-
emb1 = mock([text])[0]
|
| 166 |
-
emb2 = mock([text])[0]
|
| 167 |
-
|
| 168 |
-
assert emb1 == emb2
|
| 169 |
-
|
| 170 |
-
def test_different_texts_different_embeddings(self):
|
| 171 |
-
"""Test that different texts produce different embeddings."""
|
| 172 |
-
mock = MockEmbeddingFunction()
|
| 173 |
-
|
| 174 |
-
emb1 = mock(["First text"])[0]
|
| 175 |
-
emb2 = mock(["Second text"])[0]
|
| 176 |
-
|
| 177 |
-
assert emb1 != emb2
|
| 178 |
-
|
| 179 |
-
def test_batch_embeddings(self):
|
| 180 |
-
"""Test embedding multiple texts at once."""
|
| 181 |
-
mock = MockEmbeddingFunction()
|
| 182 |
-
texts = ["Text one", "Text two", "Text three"]
|
| 183 |
-
|
| 184 |
-
embeddings = mock(texts)
|
| 185 |
-
|
| 186 |
-
assert len(embeddings) == 3
|
| 187 |
-
assert all(len(e) == mock.EMBEDDING_DIM for e in embeddings)
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
class TestChromaVectorStore:
|
| 191 |
-
"""Test ChromaDB vector store."""
|
| 192 |
-
|
| 193 |
-
@pytest.fixture
|
| 194 |
-
def temp_dir(self):
|
| 195 |
-
"""Create a temporary directory for ChromaDB."""
|
| 196 |
-
temp = tempfile.mkdtemp()
|
| 197 |
-
yield temp
|
| 198 |
-
shutil.rmtree(temp)
|
| 199 |
-
|
| 200 |
-
@pytest.fixture
|
| 201 |
-
def vectorstore(self, temp_dir):
|
| 202 |
-
"""Create a test vector store."""
|
| 203 |
-
return ChromaVectorStore(
|
| 204 |
-
persist_directory=temp_dir,
|
| 205 |
-
embedding_function=MockEmbeddingFunction(),
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
@pytest.fixture
|
| 209 |
-
def sample_chunks(self):
|
| 210 |
-
"""Create sample chunks for testing."""
|
| 211 |
-
return [
|
| 212 |
-
Chunk(
|
| 213 |
-
id="chunk_001",
|
| 214 |
-
text="Lead threshold for non-operational facilities is 22 µg/100cm².",
|
| 215 |
-
source="fdam.md",
|
| 216 |
-
category="thresholds",
|
| 217 |
-
section="## 1.4 Thresholds",
|
| 218 |
-
priority="primary",
|
| 219 |
-
content_type="narrative",
|
| 220 |
-
keywords=["lead", "non-operational"],
|
| 221 |
-
),
|
| 222 |
-
Chunk(
|
| 223 |
-
id="chunk_002",
|
| 224 |
-
text="Burn zone requires structural assessment before cleaning.",
|
| 225 |
-
source="fdam.md",
|
| 226 |
-
category="methodology",
|
| 227 |
-
section="## 4.1 Zone Classification",
|
| 228 |
-
priority="primary",
|
| 229 |
-
content_type="narrative",
|
| 230 |
-
keywords=["burn zone", "structural damage"],
|
| 231 |
-
),
|
| 232 |
-
Chunk(
|
| 233 |
-
id="chunk_003",
|
| 234 |
-
text="HEPA vacuum is required for soot removal.",
|
| 235 |
-
source="cleaning.md",
|
| 236 |
-
category="cleaning-procedures",
|
| 237 |
-
section="## 3.2 Methods",
|
| 238 |
-
priority="reference-narrative",
|
| 239 |
-
content_type="narrative",
|
| 240 |
-
keywords=["hepa", "vacuum", "soot"],
|
| 241 |
-
),
|
| 242 |
-
]
|
| 243 |
-
|
| 244 |
-
def test_add_chunks(self, vectorstore, sample_chunks):
|
| 245 |
-
"""Test adding chunks to vector store."""
|
| 246 |
-
count = vectorstore.add_chunks(sample_chunks)
|
| 247 |
-
assert count == 3
|
| 248 |
-
|
| 249 |
-
stats = vectorstore.get_stats()
|
| 250 |
-
assert stats["total_chunks"] == 3
|
| 251 |
-
|
| 252 |
-
def test_query_returns_results(self, vectorstore, sample_chunks):
|
| 253 |
-
"""Test querying the vector store."""
|
| 254 |
-
vectorstore.add_chunks(sample_chunks)
|
| 255 |
-
|
| 256 |
-
results = vectorstore.query("lead threshold", n_results=2)
|
| 257 |
-
|
| 258 |
-
assert len(results) <= 2
|
| 259 |
-
assert all("id" in r for r in results)
|
| 260 |
-
assert all("document" in r for r in results)
|
| 261 |
-
assert all("metadata" in r for r in results)
|
| 262 |
-
assert all("distance" in r for r in results)
|
| 263 |
-
|
| 264 |
-
def test_query_with_metadata_filter(self, vectorstore, sample_chunks):
|
| 265 |
-
"""Test querying with metadata filter."""
|
| 266 |
-
vectorstore.add_chunks(sample_chunks)
|
| 267 |
-
|
| 268 |
-
results = vectorstore.query(
|
| 269 |
-
"cleaning method",
|
| 270 |
-
n_results=5,
|
| 271 |
-
where={"priority": "primary"},
|
| 272 |
-
)
|
| 273 |
-
|
| 274 |
-
# All results should have primary priority
|
| 275 |
-
for r in results:
|
| 276 |
-
assert r["metadata"]["priority"] == "primary"
|
| 277 |
-
|
| 278 |
-
def test_clear_collection(self, vectorstore, sample_chunks):
|
| 279 |
-
"""Test clearing the collection."""
|
| 280 |
-
vectorstore.add_chunks(sample_chunks)
|
| 281 |
-
assert vectorstore.get_stats()["total_chunks"] == 3
|
| 282 |
-
|
| 283 |
-
vectorstore.clear()
|
| 284 |
-
assert vectorstore.get_stats()["total_chunks"] == 0
|
| 285 |
-
|
| 286 |
-
def test_delete_by_source(self, vectorstore, sample_chunks):
|
| 287 |
-
"""Test deleting chunks by source."""
|
| 288 |
-
vectorstore.add_chunks(sample_chunks)
|
| 289 |
-
|
| 290 |
-
deleted = vectorstore.delete_by_source("fdam.md")
|
| 291 |
-
assert deleted == 2 # Two chunks from fdam.md
|
| 292 |
-
|
| 293 |
-
stats = vectorstore.get_stats()
|
| 294 |
-
assert stats["total_chunks"] == 1
|
| 295 |
-
|
| 296 |
-
def test_get_stats(self, vectorstore, sample_chunks):
|
| 297 |
-
"""Test getting collection statistics."""
|
| 298 |
-
vectorstore.add_chunks(sample_chunks)
|
| 299 |
-
|
| 300 |
-
stats = vectorstore.get_stats()
|
| 301 |
-
|
| 302 |
-
assert stats["total_chunks"] == 3
|
| 303 |
-
assert "thresholds" in stats["categories"]
|
| 304 |
-
assert "methodology" in stats["categories"]
|
| 305 |
-
assert "primary" in stats["priorities"]
|
| 306 |
-
assert "reference-narrative" in stats["priorities"]
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
class TestMockReranker:
|
| 310 |
-
"""Test mock reranker."""
|
| 311 |
-
|
| 312 |
-
def test_rerank_returns_scores(self):
|
| 313 |
-
"""Test that reranker returns scores."""
|
| 314 |
-
reranker = MockReranker()
|
| 315 |
-
query = "lead threshold contamination"
|
| 316 |
-
documents = [
|
| 317 |
-
"Lead threshold for facilities is 22 µg/100cm².",
|
| 318 |
-
"The weather is nice today.",
|
| 319 |
-
"Contamination levels require assessment.",
|
| 320 |
-
]
|
| 321 |
-
|
| 322 |
-
scores = reranker.rerank(query, documents)
|
| 323 |
-
|
| 324 |
-
assert len(scores) == 3
|
| 325 |
-
assert all(0 <= s <= 1 for s in scores)
|
| 326 |
-
|
| 327 |
-
def test_relevant_doc_higher_score(self):
|
| 328 |
-
"""Test that more relevant docs get higher scores."""
|
| 329 |
-
reranker = MockReranker()
|
| 330 |
-
query = "lead threshold"
|
| 331 |
-
documents = [
|
| 332 |
-
"Lead threshold is 22 µg.", # Very relevant
|
| 333 |
-
"Weather forecast for tomorrow.", # Not relevant
|
| 334 |
-
]
|
| 335 |
-
|
| 336 |
-
scores = reranker.rerank(query, documents)
|
| 337 |
-
|
| 338 |
-
# First doc should have higher score (shares more words)
|
| 339 |
-
assert scores[0] > scores[1]
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
class TestFDAMRetriever:
|
| 343 |
-
"""Test FDAM retriever with priority weighting."""
|
| 344 |
-
|
| 345 |
-
@pytest.fixture
|
| 346 |
-
def temp_dir(self):
|
| 347 |
-
"""Create a temporary directory."""
|
| 348 |
-
temp = tempfile.mkdtemp()
|
| 349 |
-
yield temp
|
| 350 |
-
shutil.rmtree(temp)
|
| 351 |
-
|
| 352 |
-
@pytest.fixture
|
| 353 |
-
def retriever(self, temp_dir):
|
| 354 |
-
"""Create a test retriever with sample data."""
|
| 355 |
-
vectorstore = ChromaVectorStore(
|
| 356 |
-
persist_directory=temp_dir,
|
| 357 |
-
embedding_function=MockEmbeddingFunction(),
|
| 358 |
-
)
|
| 359 |
-
|
| 360 |
-
# Add sample chunks
|
| 361 |
-
chunks = [
|
| 362 |
-
Chunk(
|
| 363 |
-
id="primary_001",
|
| 364 |
-
text="Lead threshold for non-operational is 22 µg/100cm² per FDAM.",
|
| 365 |
-
source="fdam.md",
|
| 366 |
-
category="thresholds",
|
| 367 |
-
section="## Thresholds",
|
| 368 |
-
priority="primary",
|
| 369 |
-
content_type="narrative",
|
| 370 |
-
keywords=["lead", "threshold", "non-operational"],
|
| 371 |
-
),
|
| 372 |
-
Chunk(
|
| 373 |
-
id="ref_001",
|
| 374 |
-
text="Lead clearance levels from BNL SOP.",
|
| 375 |
-
source="bnl.md",
|
| 376 |
-
category="thresholds",
|
| 377 |
-
section="## Attachment 9.3",
|
| 378 |
-
priority="reference-threshold",
|
| 379 |
-
content_type="table",
|
| 380 |
-
keywords=["lead", "clearance"],
|
| 381 |
-
),
|
| 382 |
-
Chunk(
|
| 383 |
-
id="ref_002",
|
| 384 |
-
text="General cleaning procedures for soot removal.",
|
| 385 |
-
source="cleaning.md",
|
| 386 |
-
category="cleaning-procedures",
|
| 387 |
-
section="## Methods",
|
| 388 |
-
priority="reference-narrative",
|
| 389 |
-
content_type="narrative",
|
| 390 |
-
keywords=["cleaning", "soot"],
|
| 391 |
-
),
|
| 392 |
-
]
|
| 393 |
-
vectorstore.add_chunks(chunks)
|
| 394 |
-
|
| 395 |
-
return FDAMRetriever(
|
| 396 |
-
vectorstore=vectorstore,
|
| 397 |
-
reranker=MockReranker(),
|
| 398 |
-
use_reranking=True,
|
| 399 |
-
)
|
| 400 |
-
|
| 401 |
-
def test_retrieve_returns_results(self, retriever):
|
| 402 |
-
"""Test basic retrieval."""
|
| 403 |
-
results = retriever.retrieve("lead threshold", top_k=3)
|
| 404 |
-
|
| 405 |
-
assert len(results) <= 3
|
| 406 |
-
assert all(isinstance(r, RetrievalResult) for r in results)
|
| 407 |
-
|
| 408 |
-
def test_priority_weighting(self, retriever):
|
| 409 |
-
"""Test that primary sources get higher weight."""
|
| 410 |
-
results = retriever.retrieve("lead threshold", top_k=3)
|
| 411 |
-
|
| 412 |
-
# Find primary and reference results
|
| 413 |
-
primary_results = [r for r in results if r.priority == "primary"]
|
| 414 |
-
ref_results = [r for r in results if r.priority != "primary"]
|
| 415 |
-
|
| 416 |
-
if primary_results and ref_results:
|
| 417 |
-
# Primary should have higher weighted score (before reranking)
|
| 418 |
-
# Note: final_score includes reranking which may change order
|
| 419 |
-
primary = primary_results[0]
|
| 420 |
-
ref = ref_results[0]
|
| 421 |
-
|
| 422 |
-
# With similar similarity, primary weight (1.0) > ref weight (0.8-0.9)
|
| 423 |
-
# This test validates the weighting is applied
|
| 424 |
-
assert primary.weighted_score > 0
|
| 425 |
-
|
| 426 |
-
def test_category_filter(self, retriever):
|
| 427 |
-
"""Test filtering by category."""
|
| 428 |
-
results = retriever.retrieve(
|
| 429 |
-
"cleaning method",
|
| 430 |
-
top_k=5,
|
| 431 |
-
category_filter="cleaning-procedures",
|
| 432 |
-
)
|
| 433 |
-
|
| 434 |
-
for r in results:
|
| 435 |
-
assert r.category == "cleaning-procedures"
|
| 436 |
-
|
| 437 |
-
def test_priority_filter(self, retriever):
|
| 438 |
-
"""Test filtering by priority."""
|
| 439 |
-
results = retriever.retrieve(
|
| 440 |
-
"threshold",
|
| 441 |
-
top_k=5,
|
| 442 |
-
priority_filter="primary",
|
| 443 |
-
)
|
| 444 |
-
|
| 445 |
-
for r in results:
|
| 446 |
-
assert r.priority == "primary"
|
| 447 |
-
|
| 448 |
-
def test_retrieve_for_context(self, retriever):
|
| 449 |
-
"""Test context string generation."""
|
| 450 |
-
context = retriever.retrieve_for_context("lead threshold", top_k=2)
|
| 451 |
-
|
| 452 |
-
assert isinstance(context, str)
|
| 453 |
-
assert "Source:" in context or "No relevant context" in context
|
| 454 |
-
|
| 455 |
-
def test_retrieve_thresholds(self, retriever):
|
| 456 |
-
"""Test threshold-specific retrieval."""
|
| 457 |
-
results = retriever.retrieve_thresholds(
|
| 458 |
-
material_type="lead",
|
| 459 |
-
facility_type="non-operational",
|
| 460 |
-
)
|
| 461 |
-
|
| 462 |
-
assert len(results) <= 3
|
| 463 |
-
# Should filter to thresholds category
|
| 464 |
-
for r in results:
|
| 465 |
-
assert r.category == "thresholds"
|
| 466 |
-
|
| 467 |
-
def test_retrieve_disposition(self, retriever):
|
| 468 |
-
"""Test disposition-specific retrieval."""
|
| 469 |
-
results = retriever.retrieve_disposition(
|
| 470 |
-
zone="burn-zone",
|
| 471 |
-
condition="heavy",
|
| 472 |
-
)
|
| 473 |
-
|
| 474 |
-
# Should prefer primary sources
|
| 475 |
-
if results:
|
| 476 |
-
assert results[0].priority == "primary"
|
| 477 |
-
|
| 478 |
-
def test_result_to_dict(self, retriever):
|
| 479 |
-
"""Test RetrievalResult to_dict method."""
|
| 480 |
-
results = retriever.retrieve("test", top_k=1)
|
| 481 |
-
|
| 482 |
-
if results:
|
| 483 |
-
result_dict = results[0].to_dict()
|
| 484 |
-
assert "chunk_id" in result_dict
|
| 485 |
-
assert "text" in result_dict
|
| 486 |
-
assert "source" in result_dict
|
| 487 |
-
assert "similarity_score" in result_dict
|
| 488 |
-
assert "final_score" in result_dict
|
| 489 |
-
|
| 490 |
-
def test_empty_query_handling(self, retriever):
|
| 491 |
-
"""Test handling of query with no good matches."""
|
| 492 |
-
results = retriever.retrieve(
|
| 493 |
-
"completely unrelated xyz123",
|
| 494 |
-
top_k=5,
|
| 495 |
-
category_filter="thresholds",
|
| 496 |
-
)
|
| 497 |
-
|
| 498 |
-
# Should still return results (just lower scores)
|
| 499 |
-
assert isinstance(results, list)
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
class TestChunkFile:
|
| 503 |
-
"""Test the chunk_file convenience function."""
|
| 504 |
-
|
| 505 |
-
@pytest.fixture
|
| 506 |
-
def temp_md_file(self):
|
| 507 |
-
"""Create a temporary markdown file."""
|
| 508 |
-
temp = tempfile.NamedTemporaryFile(
|
| 509 |
-
mode="w",
|
| 510 |
-
suffix=".md",
|
| 511 |
-
delete=False,
|
| 512 |
-
encoding="utf-8",
|
| 513 |
-
)
|
| 514 |
-
temp.write("""## Test Document
|
| 515 |
-
|
| 516 |
-
This is test content for chunking.
|
| 517 |
-
|
| 518 |
-
| Column A | Column B |
|
| 519 |
-
|----------|----------|
|
| 520 |
-
| Value 1 | Value 2 |
|
| 521 |
-
""")
|
| 522 |
-
temp.close()
|
| 523 |
-
yield Path(temp.name)
|
| 524 |
-
Path(temp.name).unlink()
|
| 525 |
-
|
| 526 |
-
def test_chunk_file(self, temp_md_file):
|
| 527 |
-
"""Test chunking a file directly."""
|
| 528 |
-
chunks = chunk_file(
|
| 529 |
-
filepath=temp_md_file,
|
| 530 |
-
category="methodology",
|
| 531 |
-
priority="primary",
|
| 532 |
-
)
|
| 533 |
-
|
| 534 |
-
assert len(chunks) >= 1
|
| 535 |
-
assert all(c.source == temp_md_file.name for c in chunks)
|
| 536 |
-
assert all(c.category == "methodology" for c in chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_samples.py
DELETED
|
@@ -1,296 +0,0 @@
|
|
| 1 |
-
"""Tests for sample room data module."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
|
| 6 |
-
from ui.samples import (
|
| 7 |
-
SAMPLE_SCENARIOS,
|
| 8 |
-
SAMPLE_SCENARIOS_BY_ID,
|
| 9 |
-
SAMPLE_IMAGES_DIR,
|
| 10 |
-
get_sample_choices,
|
| 11 |
-
load_sample,
|
| 12 |
-
load_sample_images,
|
| 13 |
-
get_scenario_by_id,
|
| 14 |
-
)
|
| 15 |
-
from ui.state import SessionState
|
| 16 |
-
from ui.components import image_store
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
class TestSampleScenarios:
|
| 20 |
-
"""Test sample scenario definitions."""
|
| 21 |
-
|
| 22 |
-
def test_four_scenarios_defined(self):
|
| 23 |
-
"""Verify exactly 4 sample scenarios are defined."""
|
| 24 |
-
assert len(SAMPLE_SCENARIOS) == 4
|
| 25 |
-
|
| 26 |
-
def test_scenario_ids_unique(self):
|
| 27 |
-
"""Verify all scenario IDs are unique."""
|
| 28 |
-
ids = [s.id for s in SAMPLE_SCENARIOS]
|
| 29 |
-
assert len(ids) == len(set(ids))
|
| 30 |
-
|
| 31 |
-
def test_scenario_ids_expected(self):
|
| 32 |
-
"""Verify expected scenario IDs exist."""
|
| 33 |
-
expected_ids = {"bar_dining", "bar_area", "kitchen", "factory"}
|
| 34 |
-
actual_ids = set(SAMPLE_SCENARIOS_BY_ID.keys())
|
| 35 |
-
assert actual_ids == expected_ids
|
| 36 |
-
|
| 37 |
-
def test_all_scenarios_have_required_fields(self):
|
| 38 |
-
"""Verify all scenarios have required data fields."""
|
| 39 |
-
for scenario in SAMPLE_SCENARIOS:
|
| 40 |
-
# Basic fields
|
| 41 |
-
assert scenario.id
|
| 42 |
-
assert scenario.name
|
| 43 |
-
assert scenario.description
|
| 44 |
-
assert scenario.image_files
|
| 45 |
-
|
| 46 |
-
# Project data required fields
|
| 47 |
-
assert "project_name" in scenario.project_data
|
| 48 |
-
assert "address" in scenario.project_data
|
| 49 |
-
assert "city" in scenario.project_data
|
| 50 |
-
assert "state" in scenario.project_data
|
| 51 |
-
assert "zip_code" in scenario.project_data
|
| 52 |
-
assert "client_name" in scenario.project_data
|
| 53 |
-
assert "fire_date" in scenario.project_data
|
| 54 |
-
assert "assessment_date" in scenario.project_data
|
| 55 |
-
assert "facility_classification" in scenario.project_data
|
| 56 |
-
assert "construction_era" in scenario.project_data
|
| 57 |
-
assert "assessor_name" in scenario.project_data
|
| 58 |
-
|
| 59 |
-
# Room data required fields
|
| 60 |
-
assert "name" in scenario.room_data
|
| 61 |
-
assert "length_ft" in scenario.room_data
|
| 62 |
-
assert "width_ft" in scenario.room_data
|
| 63 |
-
assert "ceiling_height_ft" in scenario.room_data
|
| 64 |
-
|
| 65 |
-
# Observations should have smoke/fire odor at minimum
|
| 66 |
-
assert "smoke_fire_odor" in scenario.observations_data
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
class TestSampleImages:
|
| 70 |
-
"""Test sample image file existence."""
|
| 71 |
-
|
| 72 |
-
def test_sample_images_dir_exists(self):
|
| 73 |
-
"""Verify sample images directory exists."""
|
| 74 |
-
assert SAMPLE_IMAGES_DIR.exists()
|
| 75 |
-
assert SAMPLE_IMAGES_DIR.is_dir()
|
| 76 |
-
|
| 77 |
-
def test_all_referenced_images_exist(self):
|
| 78 |
-
"""Verify all images referenced in scenarios exist on disk."""
|
| 79 |
-
missing_files = []
|
| 80 |
-
for scenario in SAMPLE_SCENARIOS:
|
| 81 |
-
for filename in scenario.image_files:
|
| 82 |
-
filepath = SAMPLE_IMAGES_DIR / filename
|
| 83 |
-
if not filepath.exists():
|
| 84 |
-
missing_files.append(f"{scenario.id}: {filename}")
|
| 85 |
-
|
| 86 |
-
assert not missing_files, f"Missing image files: {missing_files}"
|
| 87 |
-
|
| 88 |
-
def test_bar_dining_has_3_images(self):
|
| 89 |
-
"""Verify Bar & Dining scenario has 3 images."""
|
| 90 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["bar_dining"]
|
| 91 |
-
assert len(scenario.image_files) == 3
|
| 92 |
-
|
| 93 |
-
def test_bar_area_has_3_images(self):
|
| 94 |
-
"""Verify Bar Area scenario has 3 images."""
|
| 95 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["bar_area"]
|
| 96 |
-
assert len(scenario.image_files) == 3
|
| 97 |
-
|
| 98 |
-
def test_kitchen_has_6_images(self):
|
| 99 |
-
"""Verify Kitchen scenario has 6 images."""
|
| 100 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["kitchen"]
|
| 101 |
-
assert len(scenario.image_files) == 6
|
| 102 |
-
|
| 103 |
-
def test_factory_has_1_image(self):
|
| 104 |
-
"""Verify Factory scenario has 1 image."""
|
| 105 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["factory"]
|
| 106 |
-
assert len(scenario.image_files) == 1
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
class TestGetSampleChoices:
|
| 110 |
-
"""Test get_sample_choices function."""
|
| 111 |
-
|
| 112 |
-
def test_returns_list_of_tuples(self):
|
| 113 |
-
"""Verify function returns list of (label, value) tuples."""
|
| 114 |
-
choices = get_sample_choices()
|
| 115 |
-
assert isinstance(choices, list)
|
| 116 |
-
for choice in choices:
|
| 117 |
-
assert isinstance(choice, tuple)
|
| 118 |
-
assert len(choice) == 2
|
| 119 |
-
|
| 120 |
-
def test_first_choice_is_placeholder(self):
|
| 121 |
-
"""Verify first choice is the placeholder."""
|
| 122 |
-
choices = get_sample_choices()
|
| 123 |
-
label, value = choices[0]
|
| 124 |
-
assert "Select" in label
|
| 125 |
-
assert value == ""
|
| 126 |
-
|
| 127 |
-
def test_returns_5_choices(self):
|
| 128 |
-
"""Verify returns 5 choices (1 placeholder + 4 scenarios)."""
|
| 129 |
-
choices = get_sample_choices()
|
| 130 |
-
assert len(choices) == 5
|
| 131 |
-
|
| 132 |
-
def test_all_scenario_ids_in_choices(self):
|
| 133 |
-
"""Verify all scenario IDs appear in choices."""
|
| 134 |
-
choices = get_sample_choices()
|
| 135 |
-
values = [v for _, v in choices]
|
| 136 |
-
assert "bar_dining" in values
|
| 137 |
-
assert "bar_area" in values
|
| 138 |
-
assert "kitchen" in values
|
| 139 |
-
assert "factory" in values
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
class TestLoadSample:
|
| 143 |
-
"""Test load_sample function."""
|
| 144 |
-
|
| 145 |
-
def test_load_valid_scenario_returns_session(self):
|
| 146 |
-
"""Verify loading valid scenario returns SessionState."""
|
| 147 |
-
session = load_sample("bar_dining")
|
| 148 |
-
assert session is not None
|
| 149 |
-
assert isinstance(session, SessionState)
|
| 150 |
-
|
| 151 |
-
# Cleanup
|
| 152 |
-
image_store.clear()
|
| 153 |
-
|
| 154 |
-
def test_load_invalid_scenario_returns_none(self):
|
| 155 |
-
"""Verify loading invalid scenario returns None."""
|
| 156 |
-
session = load_sample("nonexistent_scenario")
|
| 157 |
-
assert session is None
|
| 158 |
-
|
| 159 |
-
def test_loaded_session_has_project_data(self):
|
| 160 |
-
"""Verify loaded session has correct project data."""
|
| 161 |
-
session = load_sample("bar_dining")
|
| 162 |
-
assert session.project.project_name == "Sample: Bar & Dining Fire Assessment"
|
| 163 |
-
assert session.project.city == "Springfield"
|
| 164 |
-
assert session.project.state == "IL"
|
| 165 |
-
|
| 166 |
-
# Cleanup
|
| 167 |
-
image_store.clear()
|
| 168 |
-
|
| 169 |
-
def test_loaded_session_has_room(self):
|
| 170 |
-
"""Verify loaded session has room data."""
|
| 171 |
-
session = load_sample("kitchen")
|
| 172 |
-
assert len(session.rooms) == 1
|
| 173 |
-
assert session.rooms[0].name == "Commercial Kitchen"
|
| 174 |
-
assert session.rooms[0].length_ft == 30.0
|
| 175 |
-
assert session.rooms[0].width_ft == 25.0
|
| 176 |
-
|
| 177 |
-
# Cleanup
|
| 178 |
-
image_store.clear()
|
| 179 |
-
|
| 180 |
-
def test_loaded_session_has_images(self):
|
| 181 |
-
"""Verify loaded session has images loaded into store."""
|
| 182 |
-
session = load_sample("bar_area")
|
| 183 |
-
assert len(session.images) == 3
|
| 184 |
-
|
| 185 |
-
# Verify images are in store
|
| 186 |
-
for img in session.images:
|
| 187 |
-
assert image_store.get(img.id) is not None
|
| 188 |
-
|
| 189 |
-
# Cleanup
|
| 190 |
-
image_store.clear()
|
| 191 |
-
|
| 192 |
-
def test_loaded_session_has_observations(self):
|
| 193 |
-
"""Verify loaded session has observations data."""
|
| 194 |
-
session = load_sample("factory")
|
| 195 |
-
assert session.observations.smoke_fire_odor is True
|
| 196 |
-
assert session.observations.odor_intensity == "strong"
|
| 197 |
-
assert session.observations.large_char_particles is True
|
| 198 |
-
|
| 199 |
-
# Cleanup
|
| 200 |
-
image_store.clear()
|
| 201 |
-
|
| 202 |
-
def test_loaded_session_tabs_marked_complete(self):
|
| 203 |
-
"""Verify loaded session has tabs marked as complete."""
|
| 204 |
-
session = load_sample("bar_dining")
|
| 205 |
-
assert session.tab1_complete is True
|
| 206 |
-
assert session.tab2_complete is True
|
| 207 |
-
assert session.tab3_complete is True
|
| 208 |
-
assert session.tab4_complete is True
|
| 209 |
-
|
| 210 |
-
# Cleanup
|
| 211 |
-
image_store.clear()
|
| 212 |
-
|
| 213 |
-
def test_facility_classification_correct(self):
|
| 214 |
-
"""Verify facility classifications are set correctly."""
|
| 215 |
-
# Restaurant scenarios should be non-operational
|
| 216 |
-
session = load_sample("bar_dining")
|
| 217 |
-
assert session.project.facility_classification == "non-operational"
|
| 218 |
-
image_store.clear()
|
| 219 |
-
|
| 220 |
-
# Factory should be operational
|
| 221 |
-
session = load_sample("factory")
|
| 222 |
-
assert session.project.facility_classification == "operational"
|
| 223 |
-
image_store.clear()
|
| 224 |
-
|
| 225 |
-
def test_construction_era_correct(self):
|
| 226 |
-
"""Verify construction eras are set correctly."""
|
| 227 |
-
# Bar scenarios should be pre-1980
|
| 228 |
-
session = load_sample("bar_area")
|
| 229 |
-
assert session.project.construction_era == "pre-1980"
|
| 230 |
-
image_store.clear()
|
| 231 |
-
|
| 232 |
-
# Kitchen should be 1980-2000
|
| 233 |
-
session = load_sample("kitchen")
|
| 234 |
-
assert session.project.construction_era == "1980-2000"
|
| 235 |
-
image_store.clear()
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
class TestGetScenarioById:
|
| 239 |
-
"""Test get_scenario_by_id function."""
|
| 240 |
-
|
| 241 |
-
def test_returns_scenario_for_valid_id(self):
|
| 242 |
-
"""Verify returns scenario for valid ID."""
|
| 243 |
-
scenario = get_scenario_by_id("kitchen")
|
| 244 |
-
assert scenario is not None
|
| 245 |
-
assert scenario.id == "kitchen"
|
| 246 |
-
assert scenario.name == "Kitchen"
|
| 247 |
-
|
| 248 |
-
def test_returns_none_for_invalid_id(self):
|
| 249 |
-
"""Verify returns None for invalid ID."""
|
| 250 |
-
scenario = get_scenario_by_id("invalid_id")
|
| 251 |
-
assert scenario is None
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
class TestLoadSampleImages:
|
| 255 |
-
"""Test load_sample_images function."""
|
| 256 |
-
|
| 257 |
-
def test_loads_images_into_store(self):
|
| 258 |
-
"""Verify images are loaded into image_store."""
|
| 259 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["bar_dining"]
|
| 260 |
-
room_id = "test-room-123"
|
| 261 |
-
|
| 262 |
-
image_metas = load_sample_images(scenario, room_id)
|
| 263 |
-
|
| 264 |
-
assert len(image_metas) == 3
|
| 265 |
-
for meta in image_metas:
|
| 266 |
-
assert meta.room_id == room_id
|
| 267 |
-
assert image_store.get(meta.id) is not None
|
| 268 |
-
|
| 269 |
-
# Cleanup
|
| 270 |
-
image_store.clear()
|
| 271 |
-
|
| 272 |
-
def test_image_metadata_has_correct_room_id(self):
|
| 273 |
-
"""Verify image metadata has correct room ID."""
|
| 274 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["factory"]
|
| 275 |
-
room_id = "factory-room-456"
|
| 276 |
-
|
| 277 |
-
image_metas = load_sample_images(scenario, room_id)
|
| 278 |
-
|
| 279 |
-
assert len(image_metas) == 1
|
| 280 |
-
assert image_metas[0].room_id == room_id
|
| 281 |
-
|
| 282 |
-
# Cleanup
|
| 283 |
-
image_store.clear()
|
| 284 |
-
|
| 285 |
-
def test_image_ids_are_unique(self):
|
| 286 |
-
"""Verify each loaded image gets a unique ID."""
|
| 287 |
-
scenario = SAMPLE_SCENARIOS_BY_ID["kitchen"]
|
| 288 |
-
room_id = "kitchen-room"
|
| 289 |
-
|
| 290 |
-
image_metas = load_sample_images(scenario, room_id)
|
| 291 |
-
|
| 292 |
-
ids = [meta.id for meta in image_metas]
|
| 293 |
-
assert len(ids) == len(set(ids)) # All unique
|
| 294 |
-
|
| 295 |
-
# Cleanup
|
| 296 |
-
image_store.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_schemas.py
DELETED
|
@@ -1,459 +0,0 @@
|
|
| 1 |
-
"""Tests for FDAM AI Pipeline Pydantic schemas."""
|
| 2 |
-
|
| 3 |
-
from datetime import date
|
| 4 |
-
|
| 5 |
-
import pytest
|
| 6 |
-
from pydantic import ValidationError
|
| 7 |
-
|
| 8 |
-
from schemas import (
|
| 9 |
-
# Input models
|
| 10 |
-
AssessmentInput,
|
| 11 |
-
Dimensions,
|
| 12 |
-
ImageMetadata,
|
| 13 |
-
ProjectInfo,
|
| 14 |
-
QualitativeObservations,
|
| 15 |
-
Room,
|
| 16 |
-
Surface,
|
| 17 |
-
get_material_category,
|
| 18 |
-
# Output models
|
| 19 |
-
AirFiltration,
|
| 20 |
-
CalculationResults,
|
| 21 |
-
CombustionIndicators,
|
| 22 |
-
ConditionAnalysis,
|
| 23 |
-
ConfidenceReport,
|
| 24 |
-
DetectedMaterial,
|
| 25 |
-
EquipmentRequirements,
|
| 26 |
-
GeneratedDocuments,
|
| 27 |
-
LaborEstimate,
|
| 28 |
-
RegulatoryFlags,
|
| 29 |
-
SampleDensity,
|
| 30 |
-
SamplingRecommendation,
|
| 31 |
-
SurfaceAreas,
|
| 32 |
-
VisionAnalysisResult,
|
| 33 |
-
ZoneAnalysis,
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
# --- Input Schema Tests ---
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
class TestMaterialCategory:
|
| 41 |
-
"""Test material category helper function."""
|
| 42 |
-
|
| 43 |
-
def test_non_porous_materials(self):
|
| 44 |
-
assert get_material_category("steel") == "non-porous"
|
| 45 |
-
assert get_material_category("concrete") == "non-porous"
|
| 46 |
-
assert get_material_category("glass") == "non-porous"
|
| 47 |
-
assert get_material_category("metal") == "non-porous"
|
| 48 |
-
assert get_material_category("cmu") == "non-porous"
|
| 49 |
-
|
| 50 |
-
def test_semi_porous_materials(self):
|
| 51 |
-
assert get_material_category("drywall-painted") == "semi-porous"
|
| 52 |
-
assert get_material_category("drywall-unpainted") == "semi-porous"
|
| 53 |
-
assert get_material_category("wood-sealed") == "semi-porous"
|
| 54 |
-
assert get_material_category("wood-unsealed") == "semi-porous"
|
| 55 |
-
|
| 56 |
-
def test_porous_materials(self):
|
| 57 |
-
assert get_material_category("carpet") == "porous"
|
| 58 |
-
assert get_material_category("carpet-pad") == "porous"
|
| 59 |
-
assert get_material_category("insulation-fiberglass") == "porous"
|
| 60 |
-
assert get_material_category("acoustic-tile") == "porous"
|
| 61 |
-
assert get_material_category("upholstery") == "porous"
|
| 62 |
-
|
| 63 |
-
def test_hvac_materials(self):
|
| 64 |
-
assert get_material_category("ductwork-rigid") == "hvac"
|
| 65 |
-
assert get_material_category("ductwork-flexible") == "hvac"
|
| 66 |
-
assert get_material_category("hvac-interior-insulation") == "hvac"
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
class TestDimensions:
|
| 70 |
-
"""Test Dimensions model."""
|
| 71 |
-
|
| 72 |
-
def test_valid_dimensions(self):
|
| 73 |
-
dims = Dimensions(length_ft=100, width_ft=50, ceiling_height_ft=20)
|
| 74 |
-
assert dims.area_sf == 5000
|
| 75 |
-
assert dims.volume_cf == 100000
|
| 76 |
-
|
| 77 |
-
def test_invalid_zero_dimension(self):
|
| 78 |
-
with pytest.raises(ValidationError):
|
| 79 |
-
Dimensions(length_ft=0, width_ft=50, ceiling_height_ft=20)
|
| 80 |
-
|
| 81 |
-
def test_invalid_negative_dimension(self):
|
| 82 |
-
with pytest.raises(ValidationError):
|
| 83 |
-
Dimensions(length_ft=-10, width_ft=50, ceiling_height_ft=20)
|
| 84 |
-
|
| 85 |
-
def test_dimension_exceeds_max(self):
|
| 86 |
-
with pytest.raises(ValidationError):
|
| 87 |
-
Dimensions(length_ft=20000, width_ft=50, ceiling_height_ft=20)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
class TestSurface:
|
| 91 |
-
"""Test Surface model."""
|
| 92 |
-
|
| 93 |
-
def test_valid_surface(self):
|
| 94 |
-
surface = Surface(
|
| 95 |
-
id="surf-001",
|
| 96 |
-
material="steel",
|
| 97 |
-
description="North wall steel panel",
|
| 98 |
-
area_sf=500,
|
| 99 |
-
)
|
| 100 |
-
assert surface.category == "non-porous"
|
| 101 |
-
assert surface.zone is None
|
| 102 |
-
assert surface.ai_detected is False
|
| 103 |
-
|
| 104 |
-
def test_surface_with_zone_and_condition(self):
|
| 105 |
-
surface = Surface(
|
| 106 |
-
id="surf-002",
|
| 107 |
-
material="carpet",
|
| 108 |
-
description="Main floor carpet",
|
| 109 |
-
area_sf=2000,
|
| 110 |
-
zone="near-field",
|
| 111 |
-
condition="moderate",
|
| 112 |
-
disposition="remove",
|
| 113 |
-
)
|
| 114 |
-
assert surface.category == "porous"
|
| 115 |
-
assert surface.zone == "near-field"
|
| 116 |
-
assert surface.condition == "moderate"
|
| 117 |
-
assert surface.disposition == "remove"
|
| 118 |
-
|
| 119 |
-
def test_invalid_material(self):
|
| 120 |
-
with pytest.raises(ValidationError):
|
| 121 |
-
Surface(
|
| 122 |
-
id="surf-003",
|
| 123 |
-
material="invalid-material",
|
| 124 |
-
description="Test surface",
|
| 125 |
-
area_sf=100,
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
class TestRoom:
|
| 130 |
-
"""Test Room model."""
|
| 131 |
-
|
| 132 |
-
def test_valid_room(self):
|
| 133 |
-
room = Room(
|
| 134 |
-
id="room-001",
|
| 135 |
-
name="Warehouse Bay A",
|
| 136 |
-
dimensions=Dimensions(length_ft=100, width_ft=50, ceiling_height_ft=20),
|
| 137 |
-
)
|
| 138 |
-
assert room.zone_classification is None
|
| 139 |
-
assert len(room.surfaces) == 0
|
| 140 |
-
assert len(room.image_ids) == 0
|
| 141 |
-
|
| 142 |
-
def test_room_with_surfaces(self):
|
| 143 |
-
room = Room(
|
| 144 |
-
id="room-002",
|
| 145 |
-
name="Office Space",
|
| 146 |
-
floor="Ground Floor",
|
| 147 |
-
dimensions=Dimensions(length_ft=30, width_ft=20, ceiling_height_ft=10),
|
| 148 |
-
zone_classification="far-field",
|
| 149 |
-
zone_confidence=0.85,
|
| 150 |
-
surfaces=[
|
| 151 |
-
Surface(
|
| 152 |
-
id="surf-001",
|
| 153 |
-
material="drywall-painted",
|
| 154 |
-
description="North wall",
|
| 155 |
-
area_sf=300,
|
| 156 |
-
),
|
| 157 |
-
Surface(
|
| 158 |
-
id="surf-002",
|
| 159 |
-
material="carpet",
|
| 160 |
-
description="Floor carpet",
|
| 161 |
-
area_sf=600,
|
| 162 |
-
),
|
| 163 |
-
],
|
| 164 |
-
)
|
| 165 |
-
assert len(room.surfaces) == 2
|
| 166 |
-
assert room.zone_classification == "far-field"
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
class TestProjectInfo:
|
| 170 |
-
"""Test ProjectInfo model."""
|
| 171 |
-
|
| 172 |
-
def test_valid_project_info(self):
|
| 173 |
-
project = ProjectInfo(
|
| 174 |
-
project_name="ABC Warehouse Fire",
|
| 175 |
-
address="123 Main Street",
|
| 176 |
-
city="Springfield",
|
| 177 |
-
state="IL",
|
| 178 |
-
zip_code="62701",
|
| 179 |
-
client_name="ABC Industries",
|
| 180 |
-
fire_date=date(2024, 12, 15),
|
| 181 |
-
assessment_date=date(2024, 12, 20),
|
| 182 |
-
facility_classification="non-operational",
|
| 183 |
-
construction_era="post-2000",
|
| 184 |
-
assessor_name="John Smith",
|
| 185 |
-
assessor_credentials="CIH",
|
| 186 |
-
)
|
| 187 |
-
assert project.project_name == "ABC Warehouse Fire"
|
| 188 |
-
assert project.facility_classification == "non-operational"
|
| 189 |
-
|
| 190 |
-
def test_missing_required_field(self):
|
| 191 |
-
with pytest.raises(ValidationError):
|
| 192 |
-
ProjectInfo(
|
| 193 |
-
project_name="Test Project",
|
| 194 |
-
# Missing address and other required fields
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
class TestQualitativeObservations:
|
| 199 |
-
"""Test QualitativeObservations model."""
|
| 200 |
-
|
| 201 |
-
def test_minimal_observations(self):
|
| 202 |
-
obs = QualitativeObservations(
|
| 203 |
-
smoke_fire_odor=True,
|
| 204 |
-
visible_soot_deposits=True,
|
| 205 |
-
large_char_particles=False,
|
| 206 |
-
ash_like_residue=False,
|
| 207 |
-
surface_discoloration=True,
|
| 208 |
-
dust_loading_interference=False,
|
| 209 |
-
wildfire_indicators=False,
|
| 210 |
-
)
|
| 211 |
-
assert obs.smoke_fire_odor is True
|
| 212 |
-
assert obs.odor_intensity is None
|
| 213 |
-
|
| 214 |
-
def test_full_observations(self):
|
| 215 |
-
obs = QualitativeObservations(
|
| 216 |
-
smoke_fire_odor=True,
|
| 217 |
-
odor_intensity="strong",
|
| 218 |
-
visible_soot_deposits=True,
|
| 219 |
-
soot_pattern_description="Heavy deposits on ceiling",
|
| 220 |
-
large_char_particles=True,
|
| 221 |
-
char_density_estimate="moderate",
|
| 222 |
-
ash_like_residue=True,
|
| 223 |
-
ash_color_texture="Gray powdery residue",
|
| 224 |
-
surface_discoloration=True,
|
| 225 |
-
discoloration_description="Yellowing on walls",
|
| 226 |
-
dust_loading_interference=False,
|
| 227 |
-
wildfire_indicators=False,
|
| 228 |
-
additional_notes="Structural engineer review recommended",
|
| 229 |
-
)
|
| 230 |
-
assert obs.odor_intensity == "strong"
|
| 231 |
-
assert obs.char_density_estimate == "moderate"
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
class TestAssessmentInput:
|
| 235 |
-
"""Test complete AssessmentInput model."""
|
| 236 |
-
|
| 237 |
-
@pytest.fixture
|
| 238 |
-
def sample_project(self):
|
| 239 |
-
return ProjectInfo(
|
| 240 |
-
project_name="Test Project",
|
| 241 |
-
address="123 Test St",
|
| 242 |
-
city="TestCity",
|
| 243 |
-
state="TX",
|
| 244 |
-
zip_code="12345",
|
| 245 |
-
client_name="Test Client",
|
| 246 |
-
fire_date=date(2024, 12, 1),
|
| 247 |
-
assessment_date=date(2024, 12, 15),
|
| 248 |
-
facility_classification="operational",
|
| 249 |
-
construction_era="1980-2000",
|
| 250 |
-
assessor_name="Test Assessor",
|
| 251 |
-
)
|
| 252 |
-
|
| 253 |
-
@pytest.fixture
|
| 254 |
-
def sample_room(self):
|
| 255 |
-
return Room(
|
| 256 |
-
id="room-001",
|
| 257 |
-
name="Test Room",
|
| 258 |
-
dimensions=Dimensions(length_ft=50, width_ft=30, ceiling_height_ft=12),
|
| 259 |
-
)
|
| 260 |
-
|
| 261 |
-
@pytest.fixture
|
| 262 |
-
def sample_observations(self):
|
| 263 |
-
return QualitativeObservations(
|
| 264 |
-
smoke_fire_odor=True,
|
| 265 |
-
visible_soot_deposits=True,
|
| 266 |
-
large_char_particles=False,
|
| 267 |
-
ash_like_residue=False,
|
| 268 |
-
surface_discoloration=False,
|
| 269 |
-
dust_loading_interference=False,
|
| 270 |
-
wildfire_indicators=False,
|
| 271 |
-
)
|
| 272 |
-
|
| 273 |
-
def test_valid_assessment_input(self, sample_project, sample_room, sample_observations):
|
| 274 |
-
assessment = AssessmentInput(
|
| 275 |
-
project=sample_project,
|
| 276 |
-
rooms=[sample_room],
|
| 277 |
-
observations=sample_observations,
|
| 278 |
-
)
|
| 279 |
-
assert len(assessment.rooms) == 1
|
| 280 |
-
assert len(assessment.images) == 0
|
| 281 |
-
|
| 282 |
-
def test_duplicate_room_ids(self, sample_project, sample_room, sample_observations):
|
| 283 |
-
room2 = Room(
|
| 284 |
-
id="room-001", # Same ID as sample_room
|
| 285 |
-
name="Duplicate Room",
|
| 286 |
-
dimensions=Dimensions(length_ft=20, width_ft=20, ceiling_height_ft=10),
|
| 287 |
-
)
|
| 288 |
-
with pytest.raises(ValidationError) as exc_info:
|
| 289 |
-
AssessmentInput(
|
| 290 |
-
project=sample_project,
|
| 291 |
-
rooms=[sample_room, room2],
|
| 292 |
-
observations=sample_observations,
|
| 293 |
-
)
|
| 294 |
-
assert "Room IDs must be unique" in str(exc_info.value)
|
| 295 |
-
|
| 296 |
-
def test_image_references_invalid_room(self, sample_project, sample_room, sample_observations):
|
| 297 |
-
image = ImageMetadata(
|
| 298 |
-
id="img-001",
|
| 299 |
-
filename="test.jpg",
|
| 300 |
-
room_id="nonexistent-room",
|
| 301 |
-
)
|
| 302 |
-
with pytest.raises(ValidationError) as exc_info:
|
| 303 |
-
AssessmentInput(
|
| 304 |
-
project=sample_project,
|
| 305 |
-
rooms=[sample_room],
|
| 306 |
-
images=[image],
|
| 307 |
-
observations=sample_observations,
|
| 308 |
-
)
|
| 309 |
-
assert "references unknown room" in str(exc_info.value)
|
| 310 |
-
|
| 311 |
-
def test_valid_image_reference(self, sample_project, sample_room, sample_observations):
|
| 312 |
-
image = ImageMetadata(
|
| 313 |
-
id="img-001",
|
| 314 |
-
filename="test.jpg",
|
| 315 |
-
room_id="room-001",
|
| 316 |
-
)
|
| 317 |
-
assessment = AssessmentInput(
|
| 318 |
-
project=sample_project,
|
| 319 |
-
rooms=[sample_room],
|
| 320 |
-
images=[image],
|
| 321 |
-
observations=sample_observations,
|
| 322 |
-
)
|
| 323 |
-
assert len(assessment.images) == 1
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
# --- Output Schema Tests ---
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
class TestVisionAnalysisResult:
|
| 330 |
-
"""Test VisionAnalysisResult output model."""
|
| 331 |
-
|
| 332 |
-
def test_valid_vision_result(self):
|
| 333 |
-
result = VisionAnalysisResult(
|
| 334 |
-
zone=ZoneAnalysis(
|
| 335 |
-
classification="near-field",
|
| 336 |
-
confidence=0.85,
|
| 337 |
-
reasoning="Heavy soot deposits visible on surfaces",
|
| 338 |
-
),
|
| 339 |
-
condition=ConditionAnalysis(
|
| 340 |
-
level="moderate",
|
| 341 |
-
confidence=0.80,
|
| 342 |
-
reasoning="Visible film on surfaces",
|
| 343 |
-
),
|
| 344 |
-
materials=[
|
| 345 |
-
DetectedMaterial(
|
| 346 |
-
type="steel",
|
| 347 |
-
category="non-porous",
|
| 348 |
-
confidence=0.90,
|
| 349 |
-
location_description="Ceiling structure",
|
| 350 |
-
),
|
| 351 |
-
],
|
| 352 |
-
combustion_indicators=CombustionIndicators(
|
| 353 |
-
soot_visible=True,
|
| 354 |
-
soot_pattern="Heavy deposits on horizontal surfaces",
|
| 355 |
-
char_visible=False,
|
| 356 |
-
ash_visible=True,
|
| 357 |
-
ash_description="Gray powdery residue",
|
| 358 |
-
),
|
| 359 |
-
structural_concerns=["Beam deflection observed"],
|
| 360 |
-
access_issues=["High ceiling requires lift access"],
|
| 361 |
-
recommended_sampling_locations=[
|
| 362 |
-
SamplingRecommendation(
|
| 363 |
-
description="Center of contamination",
|
| 364 |
-
sample_type="tape_lift",
|
| 365 |
-
priority="high",
|
| 366 |
-
),
|
| 367 |
-
],
|
| 368 |
-
flags_for_review=["Zone boundary unclear"],
|
| 369 |
-
)
|
| 370 |
-
assert result.zone.classification == "near-field"
|
| 371 |
-
assert len(result.materials) == 1
|
| 372 |
-
assert result.combustion_indicators.soot_visible is True
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
class TestCalculationResults:
|
| 376 |
-
"""Test CalculationResults output model."""
|
| 377 |
-
|
| 378 |
-
def test_valid_calculation_results(self):
|
| 379 |
-
results = CalculationResults(
|
| 380 |
-
surface_areas=SurfaceAreas(
|
| 381 |
-
by_type={"steel": 5000, "carpet": 3000},
|
| 382 |
-
by_disposition={"clean": 5000, "remove": 3000},
|
| 383 |
-
total_floor_sf=8000,
|
| 384 |
-
total_surface_sf=8000,
|
| 385 |
-
total_volume_cf=160000,
|
| 386 |
-
),
|
| 387 |
-
air_filtration=AirFiltration(
|
| 388 |
-
total_volume_cf=160000,
|
| 389 |
-
required_ach=4,
|
| 390 |
-
unit_cfm=2000,
|
| 391 |
-
units_required=6,
|
| 392 |
-
calculation="(160,000 CF x 4 ACH) / (2000 CFM x 60) = 6 units",
|
| 393 |
-
),
|
| 394 |
-
sample_density=SampleDensity(
|
| 395 |
-
total_sf=8000,
|
| 396 |
-
size_category="5,000 - 25,000 SF",
|
| 397 |
-
surface_types_count=2,
|
| 398 |
-
surface_types=["steel", "carpet"],
|
| 399 |
-
tape_lifts_per_type="5-10",
|
| 400 |
-
surface_wipes_per_type="5-10",
|
| 401 |
-
recommended_tape_lifts=20,
|
| 402 |
-
recommended_surface_wipes=20,
|
| 403 |
-
),
|
| 404 |
-
labor_estimate=LaborEstimate(
|
| 405 |
-
hepa_vacuum=10,
|
| 406 |
-
wet_wipe=25,
|
| 407 |
-
removal=15,
|
| 408 |
-
total_hours=50,
|
| 409 |
-
),
|
| 410 |
-
equipment=EquipmentRequirements(
|
| 411 |
-
air_scrubbers=6,
|
| 412 |
-
hepa_vacuums=2,
|
| 413 |
-
),
|
| 414 |
-
regulatory_flags=RegulatoryFlags(),
|
| 415 |
-
)
|
| 416 |
-
assert results.air_filtration.units_required == 6
|
| 417 |
-
assert results.labor_estimate.total_hours == 50
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
class TestConfidenceReport:
|
| 421 |
-
"""Test ConfidenceReport output model."""
|
| 422 |
-
|
| 423 |
-
def test_high_confidence_report(self):
|
| 424 |
-
report = ConfidenceReport(
|
| 425 |
-
flagged_items=[],
|
| 426 |
-
overall_confidence=0.92,
|
| 427 |
-
review_required=False,
|
| 428 |
-
)
|
| 429 |
-
assert report.review_required is False
|
| 430 |
-
|
| 431 |
-
def test_low_confidence_report(self):
|
| 432 |
-
from schemas import FlaggedItem
|
| 433 |
-
|
| 434 |
-
report = ConfidenceReport(
|
| 435 |
-
flagged_items=[
|
| 436 |
-
FlaggedItem(
|
| 437 |
-
type="zone_classification",
|
| 438 |
-
room="Warehouse Bay A",
|
| 439 |
-
confidence=0.55,
|
| 440 |
-
recommendation="Professional review recommended",
|
| 441 |
-
),
|
| 442 |
-
],
|
| 443 |
-
overall_confidence=0.55,
|
| 444 |
-
review_required=True,
|
| 445 |
-
)
|
| 446 |
-
assert report.review_required is True
|
| 447 |
-
assert len(report.flagged_items) == 1
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
class TestGeneratedDocuments:
|
| 451 |
-
"""Test GeneratedDocuments output model."""
|
| 452 |
-
|
| 453 |
-
def test_valid_documents(self):
|
| 454 |
-
docs = GeneratedDocuments(
|
| 455 |
-
cleaning_specification_md="# Cleaning Specification\n\n## Scope of Work...",
|
| 456 |
-
sampling_plan_md="# Sampling Plan\n\n## Recommendations...",
|
| 457 |
-
confidence_report_md="# Confidence Report\n\n## Summary...",
|
| 458 |
-
)
|
| 459 |
-
assert "Cleaning Specification" in docs.cleaning_specification_md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_tabs.py
DELETED
|
@@ -1,401 +0,0 @@
|
|
| 1 |
-
"""Tests for tab UI modules."""
|
| 2 |
-
|
| 3 |
-
import pytest
|
| 4 |
-
from PIL import Image
|
| 5 |
-
import io
|
| 6 |
-
|
| 7 |
-
from ui.state import SessionState, RoomFormData, ImageFormData
|
| 8 |
-
from ui.tabs import project, rooms, images, observations, results
|
| 9 |
-
from ui.components import image_store
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class TestProjectTab:
|
| 13 |
-
"""Test Tab 1: Project Info."""
|
| 14 |
-
|
| 15 |
-
def test_update_session_from_form(self):
|
| 16 |
-
session = SessionState()
|
| 17 |
-
session = project.update_session_from_form(
|
| 18 |
-
session,
|
| 19 |
-
project_name="Test Project",
|
| 20 |
-
address="123 Main St",
|
| 21 |
-
city="Springfield",
|
| 22 |
-
state="IL",
|
| 23 |
-
zip_code="62701",
|
| 24 |
-
client_name="Test Client",
|
| 25 |
-
fire_date="2024-12-01",
|
| 26 |
-
assessment_date="2024-12-15",
|
| 27 |
-
facility_classification="Operational",
|
| 28 |
-
construction_era="Pre-1980",
|
| 29 |
-
assessor_name="John Smith",
|
| 30 |
-
assessor_credentials=["CIH"],
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
assert session.project.project_name == "Test Project"
|
| 34 |
-
assert session.project.facility_classification == "operational"
|
| 35 |
-
assert session.project.construction_era == "pre-1980"
|
| 36 |
-
|
| 37 |
-
def test_validate_and_continue_incomplete(self):
|
| 38 |
-
session = SessionState()
|
| 39 |
-
session, html, tab_index = project.validate_and_continue(
|
| 40 |
-
session,
|
| 41 |
-
project_name="", # Missing
|
| 42 |
-
address="123 Main",
|
| 43 |
-
city="City",
|
| 44 |
-
state="IL",
|
| 45 |
-
zip_code="12345",
|
| 46 |
-
client_name="Client",
|
| 47 |
-
fire_date="2024-01-01",
|
| 48 |
-
assessment_date="2024-01-02",
|
| 49 |
-
facility_classification="Non-Operational",
|
| 50 |
-
construction_era="Post-2000",
|
| 51 |
-
assessor_name="Name",
|
| 52 |
-
assessor_credentials=[],
|
| 53 |
-
)
|
| 54 |
-
|
| 55 |
-
assert tab_index["selected"] == 0 # Stay on tab (Gradio update dict)
|
| 56 |
-
assert "Project name is required" in html
|
| 57 |
-
assert session.tab1_complete is False
|
| 58 |
-
|
| 59 |
-
def test_validate_and_continue_complete(self):
|
| 60 |
-
session = SessionState()
|
| 61 |
-
session, html, tab_index = project.validate_and_continue(
|
| 62 |
-
session,
|
| 63 |
-
project_name="Test",
|
| 64 |
-
address="123 Main",
|
| 65 |
-
city="City",
|
| 66 |
-
state="IL",
|
| 67 |
-
zip_code="12345",
|
| 68 |
-
client_name="Client",
|
| 69 |
-
fire_date="2024-01-01",
|
| 70 |
-
assessment_date="2024-01-02",
|
| 71 |
-
facility_classification="Non-Operational",
|
| 72 |
-
construction_era="Post-2000",
|
| 73 |
-
assessor_name="Name",
|
| 74 |
-
assessor_credentials=[],
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
assert tab_index["selected"] == 1 # Go to next tab (Gradio update dict)
|
| 78 |
-
assert "✓" in html
|
| 79 |
-
assert session.tab1_complete is True
|
| 80 |
-
|
| 81 |
-
def test_load_form_from_session(self):
|
| 82 |
-
session = SessionState()
|
| 83 |
-
session.project.project_name = "Loaded Project"
|
| 84 |
-
session.project.facility_classification = "public-childcare"
|
| 85 |
-
session.project.construction_era = "1980-2000"
|
| 86 |
-
|
| 87 |
-
values = project.load_form_from_session(session)
|
| 88 |
-
|
| 89 |
-
assert values[0] == "Loaded Project" # project_name
|
| 90 |
-
assert values[8] == "Public/Childcare" # facility_classification (UI value)
|
| 91 |
-
assert values[9] == "1980-2000" # construction_era (UI value)
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
class TestRoomsTab:
|
| 95 |
-
"""Test Tab 2: Building/Rooms."""
|
| 96 |
-
|
| 97 |
-
def test_add_room_valid(self):
|
| 98 |
-
session = SessionState()
|
| 99 |
-
|
| 100 |
-
result = rooms.add_room(
|
| 101 |
-
session,
|
| 102 |
-
name="Room 1",
|
| 103 |
-
floor="Ground Floor",
|
| 104 |
-
length=100.0,
|
| 105 |
-
width=50.0,
|
| 106 |
-
height_preset=20, # Using preset value
|
| 107 |
-
height_custom=None,
|
| 108 |
-
)
|
| 109 |
-
|
| 110 |
-
session = result[0]
|
| 111 |
-
table_data = result[1]
|
| 112 |
-
validation_html = result[2]
|
| 113 |
-
|
| 114 |
-
assert len(session.rooms) == 1
|
| 115 |
-
assert session.rooms[0].name == "Room 1"
|
| 116 |
-
assert "✓" in validation_html
|
| 117 |
-
assert len(table_data) == 1
|
| 118 |
-
|
| 119 |
-
def test_add_room_invalid(self):
|
| 120 |
-
session = SessionState()
|
| 121 |
-
|
| 122 |
-
result = rooms.add_room(
|
| 123 |
-
session,
|
| 124 |
-
name="", # Missing
|
| 125 |
-
floor=None,
|
| 126 |
-
length=0, # Invalid
|
| 127 |
-
width=50.0,
|
| 128 |
-
height_preset=None, # No height selected
|
| 129 |
-
height_custom=None,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
session = result[0]
|
| 133 |
-
validation_html = result[2]
|
| 134 |
-
|
| 135 |
-
assert len(session.rooms) == 0
|
| 136 |
-
assert "Room name is required" in validation_html
|
| 137 |
-
assert "Length must be greater than 0" in validation_html
|
| 138 |
-
|
| 139 |
-
def test_remove_last_room(self):
|
| 140 |
-
session = SessionState()
|
| 141 |
-
session.rooms.append(RoomFormData(name="Room 1", length_ft=100, width_ft=50, ceiling_height_ft=20))
|
| 142 |
-
session.rooms.append(RoomFormData(name="Room 2", length_ft=75, width_ft=40, ceiling_height_ft=15))
|
| 143 |
-
|
| 144 |
-
session, table_data, html, count, area, volume = rooms.remove_last_room(session)
|
| 145 |
-
|
| 146 |
-
assert len(session.rooms) == 1
|
| 147 |
-
assert session.rooms[0].name == "Room 1"
|
| 148 |
-
assert "Room 2" in html
|
| 149 |
-
|
| 150 |
-
def test_validate_and_continue(self):
|
| 151 |
-
session = SessionState()
|
| 152 |
-
session.rooms.append(RoomFormData(name="Room 1", length_ft=100, width_ft=50, ceiling_height_ft=20))
|
| 153 |
-
|
| 154 |
-
session, html, tab_index = rooms.validate_and_continue(session)
|
| 155 |
-
|
| 156 |
-
assert tab_index["selected"] == 2 # Go to Images tab (Gradio update dict)
|
| 157 |
-
assert session.tab2_complete is True
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
class MockFile:
|
| 161 |
-
"""Mock file object for testing gr.Files uploads."""
|
| 162 |
-
|
| 163 |
-
def __init__(self, path: str):
|
| 164 |
-
self.name = path
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
class TestImagesTab:
|
| 168 |
-
"""Test Tab 3: Images."""
|
| 169 |
-
|
| 170 |
-
def test_add_image_valid(self, tmp_path):
|
| 171 |
-
session = SessionState()
|
| 172 |
-
session.rooms.append(RoomFormData(id="room-001", name="Room 1", length_ft=100, width_ft=50, ceiling_height_ft=20))
|
| 173 |
-
|
| 174 |
-
# Create a test image file
|
| 175 |
-
test_image = Image.new("RGB", (100, 100), color="red")
|
| 176 |
-
img_path = tmp_path / "test_image.png"
|
| 177 |
-
test_image.save(img_path)
|
| 178 |
-
|
| 179 |
-
# Create mock file object
|
| 180 |
-
mock_file = MockFile(str(img_path))
|
| 181 |
-
|
| 182 |
-
result = images.add_image(
|
| 183 |
-
session,
|
| 184 |
-
files=[mock_file],
|
| 185 |
-
room_id="room-001",
|
| 186 |
-
description="Test image",
|
| 187 |
-
)
|
| 188 |
-
|
| 189 |
-
session = result[0]
|
| 190 |
-
gallery_data = result[1]
|
| 191 |
-
validation_html = result[2]
|
| 192 |
-
|
| 193 |
-
assert len(session.images) == 1
|
| 194 |
-
assert session.images[0].room_id == "room-001"
|
| 195 |
-
assert "✓" in validation_html
|
| 196 |
-
# Image should be in store
|
| 197 |
-
assert image_store.get(session.images[0].id) is not None
|
| 198 |
-
|
| 199 |
-
# Cleanup
|
| 200 |
-
image_store.clear()
|
| 201 |
-
|
| 202 |
-
def test_add_image_no_room(self, tmp_path):
|
| 203 |
-
session = SessionState()
|
| 204 |
-
|
| 205 |
-
# Create a test image file
|
| 206 |
-
test_image = Image.new("RGB", (100, 100), color="red")
|
| 207 |
-
img_path = tmp_path / "test_image.png"
|
| 208 |
-
test_image.save(img_path)
|
| 209 |
-
|
| 210 |
-
mock_file = MockFile(str(img_path))
|
| 211 |
-
|
| 212 |
-
result = images.add_image(
|
| 213 |
-
session,
|
| 214 |
-
files=[mock_file],
|
| 215 |
-
room_id="", # No room selected
|
| 216 |
-
description="",
|
| 217 |
-
)
|
| 218 |
-
|
| 219 |
-
session = result[0]
|
| 220 |
-
validation_html = result[2]
|
| 221 |
-
|
| 222 |
-
assert len(session.images) == 0
|
| 223 |
-
assert "select a room" in validation_html
|
| 224 |
-
|
| 225 |
-
def test_validate_missing_images(self):
|
| 226 |
-
session = SessionState()
|
| 227 |
-
session.rooms.append(RoomFormData(id="room-001", name="Room 1"))
|
| 228 |
-
# Add image metadata but don't store the actual image
|
| 229 |
-
session.images.append(ImageFormData(id="img-missing", filename="test.jpg", room_id="room-001"))
|
| 230 |
-
|
| 231 |
-
session, html, tab_index = images.validate_and_continue(session)
|
| 232 |
-
|
| 233 |
-
assert tab_index["selected"] == 2 # Stay on Images tab (Gradio update dict)
|
| 234 |
-
assert "re-uploaded" in html
|
| 235 |
-
|
| 236 |
-
def test_update_room_choices(self):
|
| 237 |
-
session = SessionState()
|
| 238 |
-
session.rooms.append(RoomFormData(id="room-001", name="Room 1"))
|
| 239 |
-
session.rooms.append(RoomFormData(id="room-002", name="Room 2"))
|
| 240 |
-
|
| 241 |
-
update = images.update_room_choices(session)
|
| 242 |
-
|
| 243 |
-
assert "choices" in update
|
| 244 |
-
assert len(update["choices"]) == 2
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
class TestObservationsTab:
|
| 248 |
-
"""Test Tab 4: Observations."""
|
| 249 |
-
|
| 250 |
-
def test_update_session_from_form(self):
|
| 251 |
-
session = SessionState()
|
| 252 |
-
session = observations.update_session_from_form(
|
| 253 |
-
session,
|
| 254 |
-
smoke_odor=True,
|
| 255 |
-
odor_intensity="Strong",
|
| 256 |
-
visible_soot=True,
|
| 257 |
-
soot_description="Heavy on ceiling",
|
| 258 |
-
large_char=True,
|
| 259 |
-
char_density="Moderate",
|
| 260 |
-
ash_residue=False,
|
| 261 |
-
ash_description="",
|
| 262 |
-
surface_discoloration=True,
|
| 263 |
-
discoloration_description="Yellowing",
|
| 264 |
-
dust_interference=False,
|
| 265 |
-
dust_notes="",
|
| 266 |
-
wildfire_indicators=False,
|
| 267 |
-
wildfire_notes="",
|
| 268 |
-
additional_notes="Test notes",
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
assert session.observations.smoke_fire_odor is True
|
| 272 |
-
assert session.observations.odor_intensity == "strong"
|
| 273 |
-
assert session.observations.char_density_estimate == "moderate"
|
| 274 |
-
assert session.observations.additional_notes == "Test notes"
|
| 275 |
-
|
| 276 |
-
def test_validate_and_continue(self):
|
| 277 |
-
session = SessionState()
|
| 278 |
-
|
| 279 |
-
session, html, tab_index = observations.validate_and_continue(
|
| 280 |
-
session,
|
| 281 |
-
smoke_odor=True,
|
| 282 |
-
odor_intensity="Moderate",
|
| 283 |
-
visible_soot=True,
|
| 284 |
-
soot_description="",
|
| 285 |
-
large_char=False,
|
| 286 |
-
char_density="None",
|
| 287 |
-
ash_residue=False,
|
| 288 |
-
ash_description="",
|
| 289 |
-
surface_discoloration=False,
|
| 290 |
-
discoloration_description="",
|
| 291 |
-
dust_interference=False,
|
| 292 |
-
dust_notes="",
|
| 293 |
-
wildfire_indicators=False,
|
| 294 |
-
wildfire_notes="",
|
| 295 |
-
additional_notes="",
|
| 296 |
-
)
|
| 297 |
-
|
| 298 |
-
assert tab_index["selected"] == 4 # Go to Results tab (Gradio update dict)
|
| 299 |
-
assert session.tab4_complete is True
|
| 300 |
-
|
| 301 |
-
def test_load_form_from_session(self):
|
| 302 |
-
session = SessionState()
|
| 303 |
-
session.observations.smoke_fire_odor = True
|
| 304 |
-
session.observations.odor_intensity = "strong"
|
| 305 |
-
session.observations.char_density_estimate = "dense"
|
| 306 |
-
|
| 307 |
-
values = observations.load_form_from_session(session)
|
| 308 |
-
|
| 309 |
-
assert values[0] is True # smoke_odor
|
| 310 |
-
assert values[1] == "Strong" # odor_intensity (UI value)
|
| 311 |
-
assert values[5] == "Dense" # char_density (UI value)
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
class TestResultsTab:
|
| 315 |
-
"""Test Tab 5: Generate Results."""
|
| 316 |
-
|
| 317 |
-
def test_check_preflight_incomplete(self):
|
| 318 |
-
session = SessionState()
|
| 319 |
-
# No data added
|
| 320 |
-
|
| 321 |
-
html = results.check_preflight(session)
|
| 322 |
-
|
| 323 |
-
assert "Cannot Generate" in html
|
| 324 |
-
assert "Project name is required" in html
|
| 325 |
-
|
| 326 |
-
def test_check_preflight_complete(self):
|
| 327 |
-
session = SessionState()
|
| 328 |
-
session.project.project_name = "Test"
|
| 329 |
-
session.project.address = "123 Main"
|
| 330 |
-
session.project.city = "City"
|
| 331 |
-
session.project.state = "IL"
|
| 332 |
-
session.project.zip_code = "12345"
|
| 333 |
-
session.project.client_name = "Client"
|
| 334 |
-
session.project.fire_date = "2024-01-01"
|
| 335 |
-
session.project.assessment_date = "2024-01-02"
|
| 336 |
-
session.project.assessor_name = "Assessor"
|
| 337 |
-
|
| 338 |
-
session.rooms.append(RoomFormData(
|
| 339 |
-
id="room-001",
|
| 340 |
-
name="Room 1",
|
| 341 |
-
length_ft=100,
|
| 342 |
-
width_ft=50,
|
| 343 |
-
ceiling_height_ft=20,
|
| 344 |
-
))
|
| 345 |
-
|
| 346 |
-
# Add image with actual bytes in store
|
| 347 |
-
img_id = "img-001"
|
| 348 |
-
session.images.append(ImageFormData(id=img_id, filename="test.jpg", room_id="room-001"))
|
| 349 |
-
test_image = Image.new("RGB", (100, 100), color="red")
|
| 350 |
-
img_bytes = io.BytesIO()
|
| 351 |
-
test_image.save(img_bytes, format="PNG")
|
| 352 |
-
image_store.store(img_id, img_bytes.getvalue())
|
| 353 |
-
|
| 354 |
-
html = results.check_preflight(session)
|
| 355 |
-
|
| 356 |
-
assert "Ready to Generate" in html
|
| 357 |
-
assert "Test" in html # Project name
|
| 358 |
-
|
| 359 |
-
# Cleanup
|
| 360 |
-
image_store.clear()
|
| 361 |
-
|
| 362 |
-
def test_generate_assessment_incomplete(self):
|
| 363 |
-
session = SessionState()
|
| 364 |
-
# Missing required data
|
| 365 |
-
|
| 366 |
-
result = results.generate_assessment(session)
|
| 367 |
-
|
| 368 |
-
session = result[0]
|
| 369 |
-
status = result[1]
|
| 370 |
-
sow = result[5]
|
| 371 |
-
|
| 372 |
-
assert "Error" in status
|
| 373 |
-
assert "Error" in sow
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
class TestMapConversions:
|
| 377 |
-
"""Test UI-to-schema value mappings."""
|
| 378 |
-
|
| 379 |
-
def test_facility_map(self):
|
| 380 |
-
assert project.FACILITY_MAP["Non-Operational"] == "non-operational"
|
| 381 |
-
assert project.FACILITY_MAP["Operational"] == "operational"
|
| 382 |
-
assert project.FACILITY_MAP["Public/Childcare"] == "public-childcare"
|
| 383 |
-
|
| 384 |
-
def test_facility_map_reverse(self):
|
| 385 |
-
assert project.FACILITY_MAP_REVERSE["non-operational"] == "Non-Operational"
|
| 386 |
-
assert project.FACILITY_MAP_REVERSE["operational"] == "Operational"
|
| 387 |
-
assert project.FACILITY_MAP_REVERSE["public-childcare"] == "Public/Childcare"
|
| 388 |
-
|
| 389 |
-
def test_era_map(self):
|
| 390 |
-
assert project.ERA_MAP["Pre-1980"] == "pre-1980"
|
| 391 |
-
assert project.ERA_MAP["1980-2000"] == "1980-2000"
|
| 392 |
-
assert project.ERA_MAP["Post-2000"] == "post-2000"
|
| 393 |
-
|
| 394 |
-
def test_odor_map(self):
|
| 395 |
-
assert observations.ODOR_MAP["None"] == "none"
|
| 396 |
-
assert observations.ODOR_MAP["Strong"] == "strong"
|
| 397 |
-
|
| 398 |
-
def test_char_density_map(self):
|
| 399 |
-
assert observations.CHAR_DENSITY_MAP["None"] is None
|
| 400 |
-
assert observations.CHAR_DENSITY_MAP["Sparse"] == "sparse"
|
| 401 |
-
assert observations.CHAR_DENSITY_MAP["Dense"] == "dense"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_ui_state.py
DELETED
|
@@ -1,360 +0,0 @@
|
|
| 1 |
-
"""Tests for UI state management."""
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import pytest
|
| 5 |
-
|
| 6 |
-
from ui.state import (
|
| 7 |
-
SessionState,
|
| 8 |
-
AssessmentHistory,
|
| 9 |
-
ProjectFormData,
|
| 10 |
-
RoomFormData,
|
| 11 |
-
ImageFormData,
|
| 12 |
-
ObservationsFormData,
|
| 13 |
-
create_new_session,
|
| 14 |
-
session_to_json,
|
| 15 |
-
session_from_json,
|
| 16 |
-
history_to_json,
|
| 17 |
-
history_from_json,
|
| 18 |
-
)
|
| 19 |
-
from ui.components import (
|
| 20 |
-
create_validation_message,
|
| 21 |
-
create_room_table_data,
|
| 22 |
-
create_history_dropdown_choices,
|
| 23 |
-
create_stats_dict,
|
| 24 |
-
ImageStore,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class TestSessionState:
|
| 29 |
-
"""Test SessionState model."""
|
| 30 |
-
|
| 31 |
-
def test_create_new_session(self):
|
| 32 |
-
session = create_new_session()
|
| 33 |
-
assert session.session_id is not None
|
| 34 |
-
assert len(session.session_id) == 32 # UUID hex
|
| 35 |
-
assert session.tab1_complete is False
|
| 36 |
-
assert len(session.rooms) == 0
|
| 37 |
-
|
| 38 |
-
def test_session_serialization(self):
|
| 39 |
-
session = SessionState()
|
| 40 |
-
session.project.project_name = "Test Project"
|
| 41 |
-
session.rooms.append(RoomFormData(
|
| 42 |
-
name="Room 1",
|
| 43 |
-
length_ft=100,
|
| 44 |
-
width_ft=50,
|
| 45 |
-
ceiling_height_ft=20,
|
| 46 |
-
))
|
| 47 |
-
|
| 48 |
-
# Serialize
|
| 49 |
-
json_str = session_to_json(session)
|
| 50 |
-
assert "Test Project" in json_str
|
| 51 |
-
assert "Room 1" in json_str
|
| 52 |
-
|
| 53 |
-
# Deserialize
|
| 54 |
-
loaded = session_from_json(json_str)
|
| 55 |
-
assert loaded.project.project_name == "Test Project"
|
| 56 |
-
assert len(loaded.rooms) == 1
|
| 57 |
-
assert loaded.rooms[0].name == "Room 1"
|
| 58 |
-
|
| 59 |
-
def test_session_validation_tab1_incomplete(self):
|
| 60 |
-
session = SessionState()
|
| 61 |
-
is_valid, errors = session.validate_tab1()
|
| 62 |
-
assert is_valid is False
|
| 63 |
-
assert "Project name is required" in errors
|
| 64 |
-
assert "Address is required" in errors
|
| 65 |
-
|
| 66 |
-
def test_session_validation_tab1_complete(self):
|
| 67 |
-
session = SessionState()
|
| 68 |
-
session.project = ProjectFormData(
|
| 69 |
-
project_name="Test Project",
|
| 70 |
-
address="123 Main St",
|
| 71 |
-
city="Springfield",
|
| 72 |
-
state="IL",
|
| 73 |
-
zip_code="62701",
|
| 74 |
-
client_name="Test Client",
|
| 75 |
-
fire_date="2024-12-01",
|
| 76 |
-
assessment_date="2024-12-15",
|
| 77 |
-
assessor_name="John Smith",
|
| 78 |
-
)
|
| 79 |
-
is_valid, errors = session.validate_tab1()
|
| 80 |
-
assert is_valid is True
|
| 81 |
-
assert len(errors) == 0
|
| 82 |
-
|
| 83 |
-
def test_session_validation_tab2_no_rooms(self):
|
| 84 |
-
session = SessionState()
|
| 85 |
-
is_valid, errors = session.validate_tab2()
|
| 86 |
-
assert is_valid is False
|
| 87 |
-
assert "At least one room is required" in errors
|
| 88 |
-
|
| 89 |
-
def test_session_validation_tab2_invalid_dimensions(self):
|
| 90 |
-
session = SessionState()
|
| 91 |
-
session.rooms.append(RoomFormData(
|
| 92 |
-
name="Room 1",
|
| 93 |
-
length_ft=0, # Invalid
|
| 94 |
-
width_ft=50,
|
| 95 |
-
ceiling_height_ft=20,
|
| 96 |
-
))
|
| 97 |
-
is_valid, errors = session.validate_tab2()
|
| 98 |
-
assert is_valid is False
|
| 99 |
-
assert any("Length must be greater than 0" in e for e in errors)
|
| 100 |
-
|
| 101 |
-
def test_session_validation_tab2_complete(self):
|
| 102 |
-
session = SessionState()
|
| 103 |
-
session.rooms.append(RoomFormData(
|
| 104 |
-
name="Room 1",
|
| 105 |
-
length_ft=100,
|
| 106 |
-
width_ft=50,
|
| 107 |
-
ceiling_height_ft=20,
|
| 108 |
-
))
|
| 109 |
-
is_valid, errors = session.validate_tab2()
|
| 110 |
-
assert is_valid is True
|
| 111 |
-
|
| 112 |
-
def test_session_validation_tab3_no_images(self):
|
| 113 |
-
session = SessionState()
|
| 114 |
-
is_valid, errors = session.validate_tab3()
|
| 115 |
-
assert is_valid is False
|
| 116 |
-
assert "At least one image is required" in errors
|
| 117 |
-
|
| 118 |
-
def test_session_validation_tab3_complete(self):
|
| 119 |
-
session = SessionState()
|
| 120 |
-
session.rooms.append(RoomFormData(id="room-001", name="Room 1"))
|
| 121 |
-
session.images.append(ImageFormData(
|
| 122 |
-
filename="test.jpg",
|
| 123 |
-
room_id="room-001",
|
| 124 |
-
))
|
| 125 |
-
is_valid, errors = session.validate_tab3()
|
| 126 |
-
assert is_valid is True
|
| 127 |
-
|
| 128 |
-
def test_session_can_generate(self):
|
| 129 |
-
session = SessionState()
|
| 130 |
-
# Fill all required fields
|
| 131 |
-
session.project = ProjectFormData(
|
| 132 |
-
project_name="Test",
|
| 133 |
-
address="123 Main",
|
| 134 |
-
city="City",
|
| 135 |
-
state="IL",
|
| 136 |
-
zip_code="12345",
|
| 137 |
-
client_name="Client",
|
| 138 |
-
fire_date="2024-01-01",
|
| 139 |
-
assessment_date="2024-01-02",
|
| 140 |
-
assessor_name="Assessor",
|
| 141 |
-
)
|
| 142 |
-
session.rooms.append(RoomFormData(
|
| 143 |
-
id="room-001",
|
| 144 |
-
name="Room 1",
|
| 145 |
-
length_ft=100,
|
| 146 |
-
width_ft=50,
|
| 147 |
-
ceiling_height_ft=20,
|
| 148 |
-
))
|
| 149 |
-
session.images.append(ImageFormData(
|
| 150 |
-
filename="test.jpg",
|
| 151 |
-
room_id="room-001",
|
| 152 |
-
))
|
| 153 |
-
|
| 154 |
-
can_gen, errors = session.can_generate()
|
| 155 |
-
assert can_gen is True
|
| 156 |
-
assert len(errors) == 0
|
| 157 |
-
|
| 158 |
-
def test_session_display_name(self):
|
| 159 |
-
session = SessionState()
|
| 160 |
-
# Default name from session ID
|
| 161 |
-
assert session.session_id[:8] in session.get_display_name()
|
| 162 |
-
|
| 163 |
-
# Name from project
|
| 164 |
-
session.project.project_name = "My Project"
|
| 165 |
-
assert session.get_display_name() == "My Project"
|
| 166 |
-
|
| 167 |
-
# Explicit name takes priority
|
| 168 |
-
session.name = "Custom Name"
|
| 169 |
-
assert session.get_display_name() == "Custom Name"
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
class TestAssessmentHistory:
|
| 173 |
-
"""Test AssessmentHistory model."""
|
| 174 |
-
|
| 175 |
-
def test_empty_history(self):
|
| 176 |
-
history = AssessmentHistory()
|
| 177 |
-
assert len(history.assessments) == 0
|
| 178 |
-
assert history.current_session_id is None
|
| 179 |
-
|
| 180 |
-
def test_add_assessment(self):
|
| 181 |
-
history = AssessmentHistory()
|
| 182 |
-
session = SessionState()
|
| 183 |
-
session.project.project_name = "Test"
|
| 184 |
-
|
| 185 |
-
history.add_assessment(session)
|
| 186 |
-
assert len(history.assessments) == 1
|
| 187 |
-
assert history.assessments[0].session_id == session.session_id
|
| 188 |
-
|
| 189 |
-
def test_add_assessment_updates_existing(self):
|
| 190 |
-
history = AssessmentHistory()
|
| 191 |
-
session = SessionState()
|
| 192 |
-
session.project.project_name = "Original"
|
| 193 |
-
|
| 194 |
-
history.add_assessment(session)
|
| 195 |
-
|
| 196 |
-
# Update and re-add
|
| 197 |
-
session.project.project_name = "Updated"
|
| 198 |
-
history.add_assessment(session)
|
| 199 |
-
|
| 200 |
-
# Should still have only 1 entry
|
| 201 |
-
assert len(history.assessments) == 1
|
| 202 |
-
assert history.assessments[0].project.project_name == "Updated"
|
| 203 |
-
|
| 204 |
-
def test_history_limit(self):
|
| 205 |
-
history = AssessmentHistory()
|
| 206 |
-
|
| 207 |
-
# Add 25 assessments
|
| 208 |
-
for i in range(25):
|
| 209 |
-
session = SessionState()
|
| 210 |
-
session.project.project_name = f"Project {i}"
|
| 211 |
-
history.add_assessment(session)
|
| 212 |
-
|
| 213 |
-
# Should only keep 20
|
| 214 |
-
assert len(history.assessments) == 20
|
| 215 |
-
# Most recent should be first
|
| 216 |
-
assert history.assessments[0].project.project_name == "Project 24"
|
| 217 |
-
|
| 218 |
-
def test_get_assessment(self):
|
| 219 |
-
history = AssessmentHistory()
|
| 220 |
-
session = SessionState()
|
| 221 |
-
history.add_assessment(session)
|
| 222 |
-
|
| 223 |
-
retrieved = history.get_assessment(session.session_id)
|
| 224 |
-
assert retrieved is not None
|
| 225 |
-
assert retrieved.session_id == session.session_id
|
| 226 |
-
|
| 227 |
-
# Non-existent
|
| 228 |
-
assert history.get_assessment("nonexistent") is None
|
| 229 |
-
|
| 230 |
-
def test_remove_assessment(self):
|
| 231 |
-
history = AssessmentHistory()
|
| 232 |
-
session = SessionState()
|
| 233 |
-
history.add_assessment(session)
|
| 234 |
-
|
| 235 |
-
history.remove_assessment(session.session_id)
|
| 236 |
-
assert len(history.assessments) == 0
|
| 237 |
-
|
| 238 |
-
def test_history_serialization(self):
|
| 239 |
-
history = AssessmentHistory()
|
| 240 |
-
session = SessionState()
|
| 241 |
-
session.project.project_name = "Test Project"
|
| 242 |
-
history.add_assessment(session)
|
| 243 |
-
|
| 244 |
-
json_str = history_to_json(history)
|
| 245 |
-
loaded = history_from_json(json_str)
|
| 246 |
-
|
| 247 |
-
assert len(loaded.assessments) == 1
|
| 248 |
-
assert loaded.assessments[0].project.project_name == "Test Project"
|
| 249 |
-
|
| 250 |
-
def test_history_items(self):
|
| 251 |
-
history = AssessmentHistory()
|
| 252 |
-
session = SessionState()
|
| 253 |
-
session.project.project_name = "Test Project"
|
| 254 |
-
session.has_results = True
|
| 255 |
-
history.add_assessment(session)
|
| 256 |
-
|
| 257 |
-
items = history.get_history_items()
|
| 258 |
-
assert len(items) == 1
|
| 259 |
-
assert items[0]["name"] == "Test Project"
|
| 260 |
-
assert items[0]["has_results"] is True
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
class TestUIComponents:
|
| 264 |
-
"""Test UI component helpers."""
|
| 265 |
-
|
| 266 |
-
def test_validation_message_success(self):
|
| 267 |
-
msg = create_validation_message(True, [], "All good!")
|
| 268 |
-
assert "✓" in msg
|
| 269 |
-
assert "All good!" in msg
|
| 270 |
-
|
| 271 |
-
def test_validation_message_failure(self):
|
| 272 |
-
msg = create_validation_message(False, ["Error 1", "Error 2"])
|
| 273 |
-
assert "⚠" in msg
|
| 274 |
-
assert "Error 1" in msg
|
| 275 |
-
assert "Error 2" in msg
|
| 276 |
-
|
| 277 |
-
def test_room_table_data(self):
|
| 278 |
-
session = SessionState()
|
| 279 |
-
session.rooms.append(RoomFormData(
|
| 280 |
-
name="Room 1",
|
| 281 |
-
length_ft=100,
|
| 282 |
-
width_ft=50,
|
| 283 |
-
ceiling_height_ft=20,
|
| 284 |
-
))
|
| 285 |
-
|
| 286 |
-
data = create_room_table_data(session)
|
| 287 |
-
assert len(data) == 1
|
| 288 |
-
assert data[0][0] == "Room 1"
|
| 289 |
-
assert "100 x 50 x 20" in data[0][1]
|
| 290 |
-
assert "5,000" in data[0][2] # Area
|
| 291 |
-
assert "100,000" in data[0][3] # Volume
|
| 292 |
-
|
| 293 |
-
def test_history_dropdown_choices(self):
|
| 294 |
-
history = AssessmentHistory()
|
| 295 |
-
session = SessionState()
|
| 296 |
-
session.project.project_name = "Test Project"
|
| 297 |
-
history.add_assessment(session)
|
| 298 |
-
|
| 299 |
-
choices = create_history_dropdown_choices(history)
|
| 300 |
-
assert len(choices) == 2 # "New Assessment" + 1 saved
|
| 301 |
-
assert choices[0][0] == "-- New Assessment --"
|
| 302 |
-
assert "Test Project" in choices[1][0]
|
| 303 |
-
|
| 304 |
-
def test_stats_dict(self):
|
| 305 |
-
session = SessionState()
|
| 306 |
-
session.rooms.append(RoomFormData(
|
| 307 |
-
name="Room 1",
|
| 308 |
-
length_ft=100,
|
| 309 |
-
width_ft=50,
|
| 310 |
-
ceiling_height_ft=20,
|
| 311 |
-
))
|
| 312 |
-
session.images.append(ImageFormData(filename="test.jpg", room_id="room-001"))
|
| 313 |
-
|
| 314 |
-
stats = create_stats_dict(session)
|
| 315 |
-
assert stats["rooms"] == 1
|
| 316 |
-
assert stats["images"] == 1
|
| 317 |
-
assert stats["total_floor_area_sf"] == "5,000"
|
| 318 |
-
assert stats["total_volume_cf"] == "100,000"
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
class TestImageStore:
|
| 322 |
-
"""Test ImageStore for in-memory image storage."""
|
| 323 |
-
|
| 324 |
-
def test_store_and_get(self):
|
| 325 |
-
store = ImageStore()
|
| 326 |
-
store.store("img-001", b"test image bytes")
|
| 327 |
-
|
| 328 |
-
assert store.get("img-001") == b"test image bytes"
|
| 329 |
-
assert store.get("nonexistent") is None
|
| 330 |
-
|
| 331 |
-
def test_remove(self):
|
| 332 |
-
store = ImageStore()
|
| 333 |
-
store.store("img-001", b"test")
|
| 334 |
-
store.remove("img-001")
|
| 335 |
-
|
| 336 |
-
assert store.get("img-001") is None
|
| 337 |
-
|
| 338 |
-
def test_clear(self):
|
| 339 |
-
store = ImageStore()
|
| 340 |
-
store.store("img-001", b"test1")
|
| 341 |
-
store.store("img-002", b"test2")
|
| 342 |
-
store.clear()
|
| 343 |
-
|
| 344 |
-
assert store.get("img-001") is None
|
| 345 |
-
assert store.get("img-002") is None
|
| 346 |
-
|
| 347 |
-
def test_missing_ids(self):
|
| 348 |
-
store = ImageStore()
|
| 349 |
-
store.store("img-001", b"test")
|
| 350 |
-
|
| 351 |
-
missing = store.get_missing_ids(["img-001", "img-002", "img-003"])
|
| 352 |
-
assert missing == ["img-002", "img-003"]
|
| 353 |
-
|
| 354 |
-
def test_has_all(self):
|
| 355 |
-
store = ImageStore()
|
| 356 |
-
store.store("img-001", b"test1")
|
| 357 |
-
store.store("img-002", b"test2")
|
| 358 |
-
|
| 359 |
-
assert store.has_all(["img-001", "img-002"]) is True
|
| 360 |
-
assert store.has_all(["img-001", "img-003"]) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui/__init__.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
| 2 |
|
| 3 |
from .state import (
|
| 4 |
# Form data models
|
| 5 |
-
ProjectFormData,
|
| 6 |
RoomFormData,
|
| 7 |
ImageFormData,
|
| 8 |
ObservationsFormData,
|
|
@@ -34,7 +33,6 @@ from .components import (
|
|
| 34 |
create_validation_message,
|
| 35 |
create_progress_html,
|
| 36 |
create_history_dropdown_choices,
|
| 37 |
-
create_room_table_data,
|
| 38 |
create_tab_status_indicator,
|
| 39 |
create_stats_dict,
|
| 40 |
format_validation_errors_html,
|
|
@@ -47,7 +45,6 @@ from .components import (
|
|
| 47 |
|
| 48 |
__all__ = [
|
| 49 |
# Form data models
|
| 50 |
-
"ProjectFormData",
|
| 51 |
"RoomFormData",
|
| 52 |
"ImageFormData",
|
| 53 |
"ObservationsFormData",
|
|
@@ -74,7 +71,6 @@ __all__ = [
|
|
| 74 |
"create_validation_message",
|
| 75 |
"create_progress_html",
|
| 76 |
"create_history_dropdown_choices",
|
| 77 |
-
"create_room_table_data",
|
| 78 |
"create_tab_status_indicator",
|
| 79 |
"create_stats_dict",
|
| 80 |
"format_validation_errors_html",
|
|
|
|
| 2 |
|
| 3 |
from .state import (
|
| 4 |
# Form data models
|
|
|
|
| 5 |
RoomFormData,
|
| 6 |
ImageFormData,
|
| 7 |
ObservationsFormData,
|
|
|
|
| 33 |
create_validation_message,
|
| 34 |
create_progress_html,
|
| 35 |
create_history_dropdown_choices,
|
|
|
|
| 36 |
create_tab_status_indicator,
|
| 37 |
create_stats_dict,
|
| 38 |
format_validation_errors_html,
|
|
|
|
| 45 |
|
| 46 |
__all__ = [
|
| 47 |
# Form data models
|
|
|
|
| 48 |
"RoomFormData",
|
| 49 |
"ImageFormData",
|
| 50 |
"ObservationsFormData",
|
|
|
|
| 71 |
"create_validation_message",
|
| 72 |
"create_progress_html",
|
| 73 |
"create_history_dropdown_choices",
|
|
|
|
| 74 |
"create_tab_status_indicator",
|
| 75 |
"create_stats_dict",
|
| 76 |
"format_validation_errors_html",
|
ui/components.py
CHANGED
|
@@ -90,28 +90,6 @@ def create_history_dropdown_choices(history: AssessmentHistory) -> list[tuple[st
|
|
| 90 |
return choices
|
| 91 |
|
| 92 |
|
| 93 |
-
def create_room_table_data(session: SessionState) -> list[list]:
|
| 94 |
-
"""Create data for rooms table display.
|
| 95 |
-
|
| 96 |
-
Args:
|
| 97 |
-
session: Current session state
|
| 98 |
-
|
| 99 |
-
Returns:
|
| 100 |
-
List of rows for dataframe
|
| 101 |
-
"""
|
| 102 |
-
rows = []
|
| 103 |
-
for room in session.rooms:
|
| 104 |
-
area = room.length_ft * room.width_ft
|
| 105 |
-
volume = area * room.ceiling_height_ft
|
| 106 |
-
rows.append([
|
| 107 |
-
room.name,
|
| 108 |
-
f"{room.length_ft:.0f} x {room.width_ft:.0f} x {room.ceiling_height_ft:.0f}",
|
| 109 |
-
f"{area:,.0f}",
|
| 110 |
-
f"{volume:,.0f}",
|
| 111 |
-
])
|
| 112 |
-
return rows
|
| 113 |
-
|
| 114 |
-
|
| 115 |
def create_tab_status_indicator(
|
| 116 |
tab_number: int,
|
| 117 |
is_complete: bool,
|
|
@@ -144,19 +122,17 @@ def create_stats_dict(session: SessionState) -> dict:
|
|
| 144 |
Returns:
|
| 145 |
Dictionary of statistics
|
| 146 |
"""
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
for r in session.rooms
|
| 151 |
-
)
|
| 152 |
|
| 153 |
return {
|
| 154 |
-
"
|
| 155 |
"images": len(session.images),
|
| 156 |
"total_floor_area_sf": f"{total_area:,.0f}",
|
| 157 |
"total_volume_cf": f"{total_volume:,.0f}",
|
| 158 |
-
"facility_classification":
|
| 159 |
-
"construction_era":
|
| 160 |
}
|
| 161 |
|
| 162 |
|
|
|
|
| 90 |
return choices
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
def create_tab_status_indicator(
|
| 94 |
tab_number: int,
|
| 95 |
is_complete: bool,
|
|
|
|
| 122 |
Returns:
|
| 123 |
Dictionary of statistics
|
| 124 |
"""
|
| 125 |
+
r = session.room
|
| 126 |
+
total_area = r.length_ft * r.width_ft
|
| 127 |
+
total_volume = total_area * r.ceiling_height_ft
|
|
|
|
|
|
|
| 128 |
|
| 129 |
return {
|
| 130 |
+
"room_name": r.name or "Not set",
|
| 131 |
"images": len(session.images),
|
| 132 |
"total_floor_area_sf": f"{total_area:,.0f}",
|
| 133 |
"total_volume_cf": f"{total_volume:,.0f}",
|
| 134 |
+
"facility_classification": r.facility_classification or "Not set",
|
| 135 |
+
"construction_era": r.construction_era or "Not set",
|
| 136 |
}
|
| 137 |
|
| 138 |
|
ui/samples.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"""Sample room data for testing the FDAM AI Pipeline.
|
| 2 |
|
| 3 |
-
Provides 4 pre-configured sample scenarios with complete
|
| 4 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import uuid
|
|
@@ -13,7 +14,6 @@ from PIL import Image
|
|
| 13 |
|
| 14 |
from ui.state import (
|
| 15 |
SessionState,
|
| 16 |
-
ProjectFormData,
|
| 17 |
RoomFormData,
|
| 18 |
ImageFormData,
|
| 19 |
ObservationsFormData,
|
|
@@ -32,7 +32,6 @@ class SampleScenario:
|
|
| 32 |
id: str
|
| 33 |
name: str
|
| 34 |
description: str
|
| 35 |
-
project_data: dict
|
| 36 |
room_data: dict
|
| 37 |
observations_data: dict
|
| 38 |
image_files: list[str] = field(default_factory=list)
|
|
@@ -46,26 +45,13 @@ SAMPLE_SCENARIOS = [
|
|
| 46 |
id="bar_dining",
|
| 47 |
name="Bar & Dining Area",
|
| 48 |
description="3 images",
|
| 49 |
-
project_data={
|
| 50 |
-
"project_name": "Sample: Bar & Dining Fire Assessment",
|
| 51 |
-
"address": "1234 Main Street",
|
| 52 |
-
"city": "Springfield",
|
| 53 |
-
"state": "IL",
|
| 54 |
-
"zip_code": "62701",
|
| 55 |
-
"client_name": "Sample Test Client",
|
| 56 |
-
"fire_date": "2024-11-15",
|
| 57 |
-
"assessment_date": "2024-12-01",
|
| 58 |
-
"facility_classification": "non-operational",
|
| 59 |
-
"construction_era": "pre-1980",
|
| 60 |
-
"assessor_name": "Test Assessor",
|
| 61 |
-
"assessor_credentials": ["CIH"],
|
| 62 |
-
},
|
| 63 |
room_data={
|
| 64 |
"name": "Bar & Dining Area",
|
| 65 |
-
"floor": "Ground Floor",
|
| 66 |
"length_ft": 40.0,
|
| 67 |
"width_ft": 30.0,
|
| 68 |
"ceiling_height_ft": 12.0,
|
|
|
|
|
|
|
| 69 |
},
|
| 70 |
observations_data={
|
| 71 |
"smoke_fire_odor": True,
|
|
@@ -95,26 +81,13 @@ SAMPLE_SCENARIOS = [
|
|
| 95 |
id="bar_area",
|
| 96 |
name="Bar Area",
|
| 97 |
description="3 images",
|
| 98 |
-
project_data={
|
| 99 |
-
"project_name": "Sample: Bar Area Fire Assessment",
|
| 100 |
-
"address": "1234 Main Street",
|
| 101 |
-
"city": "Springfield",
|
| 102 |
-
"state": "IL",
|
| 103 |
-
"zip_code": "62701",
|
| 104 |
-
"client_name": "Sample Test Client",
|
| 105 |
-
"fire_date": "2024-11-15",
|
| 106 |
-
"assessment_date": "2024-12-01",
|
| 107 |
-
"facility_classification": "non-operational",
|
| 108 |
-
"construction_era": "pre-1980",
|
| 109 |
-
"assessor_name": "Test Assessor",
|
| 110 |
-
"assessor_credentials": ["CIH"],
|
| 111 |
-
},
|
| 112 |
room_data={
|
| 113 |
"name": "Bar Area",
|
| 114 |
-
"floor": "Ground Floor",
|
| 115 |
"length_ft": 25.0,
|
| 116 |
"width_ft": 20.0,
|
| 117 |
"ceiling_height_ft": 14.0,
|
|
|
|
|
|
|
| 118 |
},
|
| 119 |
observations_data={
|
| 120 |
"smoke_fire_odor": True,
|
|
@@ -144,26 +117,13 @@ SAMPLE_SCENARIOS = [
|
|
| 144 |
id="kitchen",
|
| 145 |
name="Kitchen",
|
| 146 |
description="6 images",
|
| 147 |
-
project_data={
|
| 148 |
-
"project_name": "Sample: Kitchen Fire Assessment",
|
| 149 |
-
"address": "5678 Industrial Blvd",
|
| 150 |
-
"city": "Chicago",
|
| 151 |
-
"state": "IL",
|
| 152 |
-
"zip_code": "60601",
|
| 153 |
-
"client_name": "Sample Test Client",
|
| 154 |
-
"fire_date": "2024-10-20",
|
| 155 |
-
"assessment_date": "2024-11-05",
|
| 156 |
-
"facility_classification": "non-operational",
|
| 157 |
-
"construction_era": "1980-2000",
|
| 158 |
-
"assessor_name": "Test Assessor",
|
| 159 |
-
"assessor_credentials": ["CIH", "CSP"],
|
| 160 |
-
},
|
| 161 |
room_data={
|
| 162 |
"name": "Commercial Kitchen",
|
| 163 |
-
"floor": "Ground Floor",
|
| 164 |
"length_ft": 30.0,
|
| 165 |
"width_ft": 25.0,
|
| 166 |
"ceiling_height_ft": 10.0,
|
|
|
|
|
|
|
| 167 |
},
|
| 168 |
observations_data={
|
| 169 |
"smoke_fire_odor": True,
|
|
@@ -196,26 +156,13 @@ SAMPLE_SCENARIOS = [
|
|
| 196 |
id="factory",
|
| 197 |
name="Factory Area",
|
| 198 |
description="1 image",
|
| 199 |
-
project_data={
|
| 200 |
-
"project_name": "Sample: Factory Fire Assessment",
|
| 201 |
-
"address": "9999 Factory Way",
|
| 202 |
-
"city": "Detroit",
|
| 203 |
-
"state": "MI",
|
| 204 |
-
"zip_code": "48201",
|
| 205 |
-
"client_name": "Industrial Test Corp",
|
| 206 |
-
"fire_date": "2024-09-01",
|
| 207 |
-
"assessment_date": "2024-09-15",
|
| 208 |
-
"facility_classification": "operational",
|
| 209 |
-
"construction_era": "pre-1980",
|
| 210 |
-
"assessor_name": "Test Assessor",
|
| 211 |
-
"assessor_credentials": ["CIH", "PE"],
|
| 212 |
-
},
|
| 213 |
room_data={
|
| 214 |
"name": "Factory Production Area",
|
| 215 |
-
"floor": "Ground Floor",
|
| 216 |
"length_ft": 80.0,
|
| 217 |
"width_ft": 60.0,
|
| 218 |
"ceiling_height_ft": 25.0,
|
|
|
|
|
|
|
| 219 |
},
|
| 220 |
observations_data={
|
| 221 |
"smoke_fire_odor": True,
|
|
@@ -313,34 +260,33 @@ def load_sample(scenario_id: str) -> SessionState | None:
|
|
| 313 |
if not scenario:
|
| 314 |
return None
|
| 315 |
|
| 316 |
-
# Create room with unique ID
|
| 317 |
room_id = f"room-{uuid.uuid4().hex[:8]}"
|
| 318 |
room = RoomFormData(
|
| 319 |
id=room_id,
|
| 320 |
name=scenario.room_data["name"],
|
| 321 |
-
floor=scenario.room_data.get("floor", ""),
|
| 322 |
length_ft=scenario.room_data["length_ft"],
|
| 323 |
width_ft=scenario.room_data["width_ft"],
|
| 324 |
ceiling_height_ft=scenario.room_data["ceiling_height_ft"],
|
|
|
|
|
|
|
| 325 |
)
|
| 326 |
|
| 327 |
# Load images
|
| 328 |
images = load_sample_images(scenario, room_id)
|
| 329 |
|
| 330 |
-
# Create session
|
| 331 |
session = SessionState(
|
| 332 |
-
|
| 333 |
-
rooms=[room],
|
| 334 |
images=images,
|
| 335 |
observations=ObservationsFormData(**scenario.observations_data),
|
| 336 |
-
name=scenario.
|
| 337 |
)
|
| 338 |
|
| 339 |
# Mark tabs as complete since we have all data
|
| 340 |
session.tab1_complete = True
|
| 341 |
-
session.tab2_complete =
|
| 342 |
-
session.tab3_complete =
|
| 343 |
-
session.tab4_complete = True
|
| 344 |
|
| 345 |
return session
|
| 346 |
|
|
|
|
| 1 |
"""Sample room data for testing the FDAM AI Pipeline.
|
| 2 |
|
| 3 |
+
Provides 4 pre-configured sample scenarios with complete room data,
|
| 4 |
+
images, and qualitative observations.
|
| 5 |
+
MVP Simplification: Single room, no project-level data.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import uuid
|
|
|
|
| 14 |
|
| 15 |
from ui.state import (
|
| 16 |
SessionState,
|
|
|
|
| 17 |
RoomFormData,
|
| 18 |
ImageFormData,
|
| 19 |
ObservationsFormData,
|
|
|
|
| 32 |
id: str
|
| 33 |
name: str
|
| 34 |
description: str
|
|
|
|
| 35 |
room_data: dict
|
| 36 |
observations_data: dict
|
| 37 |
image_files: list[str] = field(default_factory=list)
|
|
|
|
| 45 |
id="bar_dining",
|
| 46 |
name="Bar & Dining Area",
|
| 47 |
description="3 images",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
room_data={
|
| 49 |
"name": "Bar & Dining Area",
|
|
|
|
| 50 |
"length_ft": 40.0,
|
| 51 |
"width_ft": 30.0,
|
| 52 |
"ceiling_height_ft": 12.0,
|
| 53 |
+
"facility_classification": "non-operational",
|
| 54 |
+
"construction_era": "pre-1980",
|
| 55 |
},
|
| 56 |
observations_data={
|
| 57 |
"smoke_fire_odor": True,
|
|
|
|
| 81 |
id="bar_area",
|
| 82 |
name="Bar Area",
|
| 83 |
description="3 images",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
room_data={
|
| 85 |
"name": "Bar Area",
|
|
|
|
| 86 |
"length_ft": 25.0,
|
| 87 |
"width_ft": 20.0,
|
| 88 |
"ceiling_height_ft": 14.0,
|
| 89 |
+
"facility_classification": "non-operational",
|
| 90 |
+
"construction_era": "pre-1980",
|
| 91 |
},
|
| 92 |
observations_data={
|
| 93 |
"smoke_fire_odor": True,
|
|
|
|
| 117 |
id="kitchen",
|
| 118 |
name="Kitchen",
|
| 119 |
description="6 images",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
room_data={
|
| 121 |
"name": "Commercial Kitchen",
|
|
|
|
| 122 |
"length_ft": 30.0,
|
| 123 |
"width_ft": 25.0,
|
| 124 |
"ceiling_height_ft": 10.0,
|
| 125 |
+
"facility_classification": "non-operational",
|
| 126 |
+
"construction_era": "1980-2000",
|
| 127 |
},
|
| 128 |
observations_data={
|
| 129 |
"smoke_fire_odor": True,
|
|
|
|
| 156 |
id="factory",
|
| 157 |
name="Factory Area",
|
| 158 |
description="1 image",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
room_data={
|
| 160 |
"name": "Factory Production Area",
|
|
|
|
| 161 |
"length_ft": 80.0,
|
| 162 |
"width_ft": 60.0,
|
| 163 |
"ceiling_height_ft": 25.0,
|
| 164 |
+
"facility_classification": "operational",
|
| 165 |
+
"construction_era": "pre-1980",
|
| 166 |
},
|
| 167 |
observations_data={
|
| 168 |
"smoke_fire_odor": True,
|
|
|
|
| 260 |
if not scenario:
|
| 261 |
return None
|
| 262 |
|
| 263 |
+
# Create room with unique ID and all fields from room_data
|
| 264 |
room_id = f"room-{uuid.uuid4().hex[:8]}"
|
| 265 |
room = RoomFormData(
|
| 266 |
id=room_id,
|
| 267 |
name=scenario.room_data["name"],
|
|
|
|
| 268 |
length_ft=scenario.room_data["length_ft"],
|
| 269 |
width_ft=scenario.room_data["width_ft"],
|
| 270 |
ceiling_height_ft=scenario.room_data["ceiling_height_ft"],
|
| 271 |
+
facility_classification=scenario.room_data.get("facility_classification", "non-operational"),
|
| 272 |
+
construction_era=scenario.room_data.get("construction_era", "post-2000"),
|
| 273 |
)
|
| 274 |
|
| 275 |
# Load images
|
| 276 |
images = load_sample_images(scenario, room_id)
|
| 277 |
|
| 278 |
+
# Create session with single room
|
| 279 |
session = SessionState(
|
| 280 |
+
room=room,
|
|
|
|
| 281 |
images=images,
|
| 282 |
observations=ObservationsFormData(**scenario.observations_data),
|
| 283 |
+
name=scenario.room_data["name"],
|
| 284 |
)
|
| 285 |
|
| 286 |
# Mark tabs as complete since we have all data
|
| 287 |
session.tab1_complete = True
|
| 288 |
+
session.tab2_complete = len(images) > 0
|
| 289 |
+
session.tab3_complete = True
|
|
|
|
| 290 |
|
| 291 |
return session
|
| 292 |
|
ui/state.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
| 2 |
|
| 3 |
Provides Pydantic models for session state and localStorage persistence.
|
| 4 |
Images are stored separately (not in localStorage due to size limits).
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import json
|
|
@@ -22,32 +24,21 @@ from schemas.input import (
|
|
| 22 |
# --- Form Data Models (for localStorage) ---
|
| 23 |
|
| 24 |
|
| 25 |
-
class ProjectFormData(BaseModel):
|
| 26 |
-
"""Form data for Tab 1: Project Info."""
|
| 27 |
-
|
| 28 |
-
project_name: str = ""
|
| 29 |
-
address: str = ""
|
| 30 |
-
city: str = ""
|
| 31 |
-
state: str = ""
|
| 32 |
-
zip_code: str = ""
|
| 33 |
-
client_name: str = ""
|
| 34 |
-
fire_date: str = "" # ISO format string for form compatibility
|
| 35 |
-
assessment_date: str = ""
|
| 36 |
-
facility_classification: FacilityClassification = "non-operational"
|
| 37 |
-
construction_era: ConstructionEra = "post-2000"
|
| 38 |
-
assessor_name: str = ""
|
| 39 |
-
assessor_credentials: list[str] = Field(default_factory=list) # Multiselect credentials
|
| 40 |
-
|
| 41 |
-
|
| 42 |
class RoomFormData(BaseModel):
|
| 43 |
-
"""Form data for
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
id: str = Field(default_factory=lambda: f"room-{uuid.uuid4().hex[:8]}")
|
| 46 |
name: str = ""
|
| 47 |
-
floor: str = ""
|
| 48 |
length_ft: float = 0
|
| 49 |
width_ft: float = 0
|
| 50 |
ceiling_height_ft: float = 0
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
class ImageFormData(BaseModel):
|
|
@@ -61,7 +52,7 @@ class ImageFormData(BaseModel):
|
|
| 61 |
|
| 62 |
|
| 63 |
class ObservationsFormData(BaseModel):
|
| 64 |
-
"""Form data for Tab
|
| 65 |
|
| 66 |
smoke_fire_odor: bool = False
|
| 67 |
odor_intensity: OdorIntensity = "none"
|
|
@@ -85,6 +76,9 @@ class SessionState(BaseModel):
|
|
| 85 |
|
| 86 |
This model is serialized to localStorage for persistence.
|
| 87 |
Images are stored separately and referenced by ID.
|
|
|
|
|
|
|
|
|
|
| 88 |
"""
|
| 89 |
|
| 90 |
# Session metadata
|
|
@@ -93,15 +87,13 @@ class SessionState(BaseModel):
|
|
| 93 |
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
| 94 |
name: str = "" # Display name for history list
|
| 95 |
|
| 96 |
-
# Tab completion status
|
| 97 |
-
tab1_complete: bool = False
|
| 98 |
-
tab2_complete: bool = False
|
| 99 |
-
tab3_complete: bool = False
|
| 100 |
-
tab4_complete: bool = False
|
| 101 |
|
| 102 |
-
# Form data
|
| 103 |
-
|
| 104 |
-
rooms: list[RoomFormData] = Field(default_factory=list)
|
| 105 |
images: list[ImageFormData] = Field(default_factory=list)
|
| 106 |
observations: ObservationsFormData = Field(default_factory=ObservationsFormData)
|
| 107 |
|
|
@@ -117,63 +109,37 @@ class SessionState(BaseModel):
|
|
| 117 |
"""Get a display name for the history list."""
|
| 118 |
if self.name:
|
| 119 |
return self.name
|
| 120 |
-
if self.
|
| 121 |
-
return self.
|
| 122 |
return f"Assessment {self.session_id[:8]}"
|
| 123 |
|
| 124 |
def validate_tab1(self) -> tuple[bool, list[str]]:
|
| 125 |
-
"""Validate Tab 1 (
|
| 126 |
errors = []
|
| 127 |
-
|
| 128 |
-
if not
|
| 129 |
-
errors.append("
|
| 130 |
-
if
|
| 131 |
-
errors.append("
|
| 132 |
-
if
|
| 133 |
-
errors.append("
|
| 134 |
-
if
|
| 135 |
-
errors.append("
|
| 136 |
-
if not p.zip_code:
|
| 137 |
-
errors.append("ZIP code is required")
|
| 138 |
-
if not p.client_name:
|
| 139 |
-
errors.append("Client name is required")
|
| 140 |
-
if not p.fire_date:
|
| 141 |
-
errors.append("Fire date is required")
|
| 142 |
-
if not p.assessment_date:
|
| 143 |
-
errors.append("Assessment date is required")
|
| 144 |
-
if not p.assessor_name:
|
| 145 |
-
errors.append("Assessor name is required")
|
| 146 |
return len(errors) == 0, errors
|
| 147 |
|
| 148 |
def validate_tab2(self) -> tuple[bool, list[str]]:
|
| 149 |
-
"""Validate Tab 2 (
|
| 150 |
-
errors = []
|
| 151 |
-
if not self.rooms:
|
| 152 |
-
errors.append("At least one room is required")
|
| 153 |
-
for room in self.rooms:
|
| 154 |
-
if not room.name:
|
| 155 |
-
errors.append(f"Room name is required")
|
| 156 |
-
if room.length_ft <= 0:
|
| 157 |
-
errors.append(f"Room '{room.name}': Length must be greater than 0")
|
| 158 |
-
if room.width_ft <= 0:
|
| 159 |
-
errors.append(f"Room '{room.name}': Width must be greater than 0")
|
| 160 |
-
if room.ceiling_height_ft <= 0:
|
| 161 |
-
errors.append(f"Room '{room.name}': Ceiling height must be greater than 0")
|
| 162 |
-
return len(errors) == 0, errors
|
| 163 |
-
|
| 164 |
-
def validate_tab3(self) -> tuple[bool, list[str]]:
|
| 165 |
-
"""Validate Tab 3 (Images) is complete."""
|
| 166 |
errors = []
|
| 167 |
if not self.images:
|
| 168 |
errors.append("At least one image is required")
|
| 169 |
for img in self.images:
|
| 170 |
if not img.room_id:
|
| 171 |
-
errors.append(f"Image '{img.filename}': Must be associated with
|
| 172 |
return len(errors) == 0, errors
|
| 173 |
|
| 174 |
-
def
|
| 175 |
-
"""Validate Tab
|
| 176 |
-
# Tab
|
| 177 |
return True, []
|
| 178 |
|
| 179 |
def can_generate(self) -> tuple[bool, list[str]]:
|
|
@@ -192,10 +158,6 @@ class SessionState(BaseModel):
|
|
| 192 |
if not valid3:
|
| 193 |
all_errors.extend(errors3)
|
| 194 |
|
| 195 |
-
valid4, errors4 = self.validate_tab4()
|
| 196 |
-
if not valid4:
|
| 197 |
-
all_errors.extend(errors4)
|
| 198 |
-
|
| 199 |
return len(all_errors) == 0, all_errors
|
| 200 |
|
| 201 |
|
|
@@ -255,20 +217,35 @@ def session_to_json(session: SessionState) -> str:
|
|
| 255 |
def session_from_json(json_str: str) -> SessionState:
|
| 256 |
"""Deserialize session from JSON.
|
| 257 |
|
| 258 |
-
Includes migration
|
| 259 |
"""
|
| 260 |
try:
|
| 261 |
-
# Parse JSON first to check for migrations needed
|
| 262 |
data = json.loads(json_str)
|
| 263 |
|
| 264 |
-
# Migration: Convert old
|
| 265 |
-
if "
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
data["project"]["assessor_credentials"] = [c.strip() for c in old_creds.split(",") if c.strip()]
|
| 270 |
else:
|
| 271 |
-
data["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
return SessionState.model_validate(data)
|
| 274 |
except Exception:
|
|
|
|
| 2 |
|
| 3 |
Provides Pydantic models for session state and localStorage persistence.
|
| 4 |
Images are stored separately (not in localStorage due to size limits).
|
| 5 |
+
|
| 6 |
+
MVP Simplification: Single room assessment (no project-level fields).
|
| 7 |
"""
|
| 8 |
|
| 9 |
import json
|
|
|
|
| 24 |
# --- Form Data Models (for localStorage) ---
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
class RoomFormData(BaseModel):
|
| 28 |
+
"""Form data for Tab 1: Room Assessment.
|
| 29 |
+
|
| 30 |
+
Includes facility_classification and construction_era moved from
|
| 31 |
+
the removed ProjectFormData (required for calculations).
|
| 32 |
+
"""
|
| 33 |
|
| 34 |
id: str = Field(default_factory=lambda: f"room-{uuid.uuid4().hex[:8]}")
|
| 35 |
name: str = ""
|
|
|
|
| 36 |
length_ft: float = 0
|
| 37 |
width_ft: float = 0
|
| 38 |
ceiling_height_ft: float = 0
|
| 39 |
+
# Moved from ProjectFormData (required for calculations)
|
| 40 |
+
facility_classification: FacilityClassification = "non-operational"
|
| 41 |
+
construction_era: ConstructionEra = "post-2000"
|
| 42 |
|
| 43 |
|
| 44 |
class ImageFormData(BaseModel):
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class ObservationsFormData(BaseModel):
|
| 55 |
+
"""Form data for Tab 3: Observations."""
|
| 56 |
|
| 57 |
smoke_fire_odor: bool = False
|
| 58 |
odor_intensity: OdorIntensity = "none"
|
|
|
|
| 76 |
|
| 77 |
This model is serialized to localStorage for persistence.
|
| 78 |
Images are stored separately and referenced by ID.
|
| 79 |
+
|
| 80 |
+
MVP Simplification: Single room, no project-level fields.
|
| 81 |
+
Tabs: 1=Room, 2=Images, 3=Observations, 4=Generate
|
| 82 |
"""
|
| 83 |
|
| 84 |
# Session metadata
|
|
|
|
| 87 |
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
| 88 |
name: str = "" # Display name for history list
|
| 89 |
|
| 90 |
+
# Tab completion status (3 input tabs)
|
| 91 |
+
tab1_complete: bool = False # Room Assessment
|
| 92 |
+
tab2_complete: bool = False # Images
|
| 93 |
+
tab3_complete: bool = False # Observations
|
|
|
|
| 94 |
|
| 95 |
+
# Form data - single room (not list)
|
| 96 |
+
room: RoomFormData = Field(default_factory=RoomFormData)
|
|
|
|
| 97 |
images: list[ImageFormData] = Field(default_factory=list)
|
| 98 |
observations: ObservationsFormData = Field(default_factory=ObservationsFormData)
|
| 99 |
|
|
|
|
| 109 |
"""Get a display name for the history list."""
|
| 110 |
if self.name:
|
| 111 |
return self.name
|
| 112 |
+
if self.room.name:
|
| 113 |
+
return self.room.name
|
| 114 |
return f"Assessment {self.session_id[:8]}"
|
| 115 |
|
| 116 |
def validate_tab1(self) -> tuple[bool, list[str]]:
|
| 117 |
+
"""Validate Tab 1 (Room Assessment) is complete."""
|
| 118 |
errors = []
|
| 119 |
+
r = self.room
|
| 120 |
+
if not r.name:
|
| 121 |
+
errors.append("Room name is required")
|
| 122 |
+
if r.length_ft <= 0:
|
| 123 |
+
errors.append("Length must be greater than 0")
|
| 124 |
+
if r.width_ft <= 0:
|
| 125 |
+
errors.append("Width must be greater than 0")
|
| 126 |
+
if r.ceiling_height_ft <= 0:
|
| 127 |
+
errors.append("Ceiling height must be greater than 0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
return len(errors) == 0, errors
|
| 129 |
|
| 130 |
def validate_tab2(self) -> tuple[bool, list[str]]:
|
| 131 |
+
"""Validate Tab 2 (Images) is complete."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
errors = []
|
| 133 |
if not self.images:
|
| 134 |
errors.append("At least one image is required")
|
| 135 |
for img in self.images:
|
| 136 |
if not img.room_id:
|
| 137 |
+
errors.append(f"Image '{img.filename}': Must be associated with the room")
|
| 138 |
return len(errors) == 0, errors
|
| 139 |
|
| 140 |
+
def validate_tab3(self) -> tuple[bool, list[str]]:
|
| 141 |
+
"""Validate Tab 3 (Observations) is complete."""
|
| 142 |
+
# Tab 3 has no required fields - all checkboxes default to False
|
| 143 |
return True, []
|
| 144 |
|
| 145 |
def can_generate(self) -> tuple[bool, list[str]]:
|
|
|
|
| 158 |
if not valid3:
|
| 159 |
all_errors.extend(errors3)
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
return len(all_errors) == 0, all_errors
|
| 162 |
|
| 163 |
|
|
|
|
| 217 |
def session_from_json(json_str: str) -> SessionState:
|
| 218 |
"""Deserialize session from JSON.
|
| 219 |
|
| 220 |
+
Includes migration from old multi-room format to single room.
|
| 221 |
"""
|
| 222 |
try:
|
|
|
|
| 223 |
data = json.loads(json_str)
|
| 224 |
|
| 225 |
+
# Migration: Convert old multi-room format to single room
|
| 226 |
+
if "rooms" in data and isinstance(data["rooms"], list):
|
| 227 |
+
# Use first room if available, otherwise create empty
|
| 228 |
+
if data["rooms"]:
|
| 229 |
+
data["room"] = data["rooms"][0]
|
|
|
|
| 230 |
else:
|
| 231 |
+
data["room"] = {}
|
| 232 |
+
del data["rooms"]
|
| 233 |
+
|
| 234 |
+
# Migration: Move facility_classification and construction_era from project to room
|
| 235 |
+
if "project" in data:
|
| 236 |
+
project = data["project"]
|
| 237 |
+
if "room" not in data:
|
| 238 |
+
data["room"] = {}
|
| 239 |
+
# Move fields if they exist in project
|
| 240 |
+
if "facility_classification" in project:
|
| 241 |
+
data["room"]["facility_classification"] = project["facility_classification"]
|
| 242 |
+
if "construction_era" in project:
|
| 243 |
+
data["room"]["construction_era"] = project["construction_era"]
|
| 244 |
+
del data["project"]
|
| 245 |
+
|
| 246 |
+
# Migration: Remove old tab4_complete (now only 3 tabs)
|
| 247 |
+
if "tab4_complete" in data:
|
| 248 |
+
del data["tab4_complete"]
|
| 249 |
|
| 250 |
return SessionState.model_validate(data)
|
| 251 |
except Exception:
|
ui/tabs/__init__.py
CHANGED
|
@@ -1,14 +1,12 @@
|
|
| 1 |
"""Tab modules for FDAM AI Pipeline UI."""
|
| 2 |
|
| 3 |
-
from . import
|
| 4 |
-
from . import rooms
|
| 5 |
from . import images
|
| 6 |
from . import observations
|
| 7 |
from . import results
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
-
"
|
| 11 |
-
"rooms",
|
| 12 |
"images",
|
| 13 |
"observations",
|
| 14 |
"results",
|
|
|
|
| 1 |
"""Tab modules for FDAM AI Pipeline UI."""
|
| 2 |
|
| 3 |
+
from . import room
|
|
|
|
| 4 |
from . import images
|
| 5 |
from . import observations
|
| 6 |
from . import results
|
| 7 |
|
| 8 |
__all__ = [
|
| 9 |
+
"room",
|
|
|
|
| 10 |
"images",
|
| 11 |
"observations",
|
| 12 |
"results",
|
ui/tabs/images.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
-
"""Tab
|
| 2 |
|
| 3 |
Upload and manage fire damage images for AI analysis.
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import uuid
|
| 7 |
import gradio as gr
|
| 8 |
-
from typing import Any
|
| 9 |
from PIL import Image
|
| 10 |
import io
|
| 11 |
|
|
@@ -15,15 +16,14 @@ from config.settings import settings
|
|
| 15 |
|
| 16 |
|
| 17 |
def create_tab() -> dict[str, Any]:
|
| 18 |
-
"""Create Tab
|
| 19 |
|
| 20 |
Returns:
|
| 21 |
Dictionary of component references for event wiring.
|
| 22 |
"""
|
| 23 |
gr.Markdown("### Fire Damage Images")
|
| 24 |
gr.Markdown(
|
| 25 |
-
f"*Upload up to {settings.max_images_per_assessment} images for AI analysis
|
| 26 |
-
f"Each image must be associated with a room.*"
|
| 27 |
)
|
| 28 |
|
| 29 |
with gr.Row():
|
|
@@ -34,13 +34,6 @@ def create_tab() -> dict[str, Any]:
|
|
| 34 |
file_types=["image"],
|
| 35 |
elem_id="image_upload",
|
| 36 |
)
|
| 37 |
-
room_select = gr.Dropdown(
|
| 38 |
-
label="Associate with Room *",
|
| 39 |
-
choices=[],
|
| 40 |
-
value=None,
|
| 41 |
-
elem_id="room_select",
|
| 42 |
-
info="All uploaded images will be assigned to this room",
|
| 43 |
-
)
|
| 44 |
image_description = gr.Textbox(
|
| 45 |
label="Description (optional)",
|
| 46 |
placeholder="e.g., View of ceiling deck from center aisle",
|
|
@@ -75,7 +68,7 @@ def create_tab() -> dict[str, Any]:
|
|
| 75 |
with gr.Row():
|
| 76 |
validation_status = gr.HTML(
|
| 77 |
value="",
|
| 78 |
-
elem_id="
|
| 79 |
)
|
| 80 |
|
| 81 |
# Resume warning (shown when images need re-upload)
|
|
@@ -87,7 +80,7 @@ def create_tab() -> dict[str, Any]:
|
|
| 87 |
)
|
| 88 |
|
| 89 |
with gr.Row():
|
| 90 |
-
back_btn = gr.Button("← Back to
|
| 91 |
validate_btn = gr.Button(
|
| 92 |
"Validate & Continue to Observations →",
|
| 93 |
variant="primary",
|
|
@@ -95,7 +88,6 @@ def create_tab() -> dict[str, Any]:
|
|
| 95 |
|
| 96 |
return {
|
| 97 |
"image_upload": image_upload,
|
| 98 |
-
"room_select": room_select,
|
| 99 |
"image_description": image_description,
|
| 100 |
"add_image_btn": add_image_btn,
|
| 101 |
"clear_upload_btn": clear_upload_btn,
|
|
@@ -113,20 +105,20 @@ def create_tab() -> dict[str, Any]:
|
|
| 113 |
def add_image(
|
| 114 |
session: SessionState,
|
| 115 |
files: list | None,
|
| 116 |
-
room_id: str,
|
| 117 |
description: str,
|
| 118 |
-
) -> tuple[SessionState, list[tuple], str, str, None, str
|
| 119 |
"""Add one or more images to the session (batch upload).
|
| 120 |
|
|
|
|
|
|
|
| 121 |
Args:
|
| 122 |
session: Current session state.
|
| 123 |
files: List of uploaded file objects from gr.Files, each with a `name` attribute.
|
| 124 |
-
room_id: Room ID to associate images with.
|
| 125 |
description: Optional description applied to all images.
|
| 126 |
|
| 127 |
Returns:
|
| 128 |
Tuple of (session, gallery_data, validation_html, image_count,
|
| 129 |
-
cleared_files, cleared_description
|
| 130 |
"""
|
| 131 |
validation_html = ""
|
| 132 |
|
|
@@ -134,8 +126,6 @@ def add_image(
|
|
| 134 |
errors = []
|
| 135 |
if not files or len(files) == 0:
|
| 136 |
errors.append("Please upload at least one image")
|
| 137 |
-
if not room_id:
|
| 138 |
-
errors.append("Please select a room for these images")
|
| 139 |
|
| 140 |
# Check capacity
|
| 141 |
current_count = len(session.images)
|
|
@@ -158,14 +148,11 @@ def add_image(
|
|
| 158 |
"""
|
| 159 |
gallery_data = _get_gallery_data(session)
|
| 160 |
count_str = f"{len(session.images)} / {max_allowed}"
|
| 161 |
-
return session, gallery_data, validation_html, count_str, files, description
|
| 162 |
|
| 163 |
-
# Get room
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
if room.id == room_id:
|
| 167 |
-
room_name = room.name.replace(" ", "_")[:20]
|
| 168 |
-
break
|
| 169 |
|
| 170 |
# Process each uploaded file
|
| 171 |
added_count = 0
|
|
@@ -205,7 +192,7 @@ def add_image(
|
|
| 205 |
if added_count > 0:
|
| 206 |
validation_html = f"""
|
| 207 |
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 208 |
-
<span style="color: #2e7d32;">✓ {added_count} image(s) added for
|
| 209 |
</div>
|
| 210 |
"""
|
| 211 |
else:
|
|
@@ -219,7 +206,7 @@ def add_image(
|
|
| 219 |
count_str = f"{len(session.images)} / {max_allowed}"
|
| 220 |
|
| 221 |
# Clear form
|
| 222 |
-
return session, gallery_data, validation_html, count_str, None, ""
|
| 223 |
|
| 224 |
|
| 225 |
def remove_last_image(session: SessionState) -> tuple[SessionState, list[tuple], str, str]:
|
|
@@ -265,7 +252,7 @@ def clear_all_images(session: SessionState) -> tuple[SessionState, list[tuple],
|
|
| 265 |
|
| 266 |
|
| 267 |
def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int]:
|
| 268 |
-
"""Validate Tab
|
| 269 |
|
| 270 |
Returns:
|
| 271 |
Tuple of (session, validation_html, next_tab_index).
|
|
@@ -285,21 +272,21 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
|
|
| 285 |
</p>
|
| 286 |
</div>
|
| 287 |
"""
|
| 288 |
-
return session, html, gr.update(selected=
|
| 289 |
|
| 290 |
-
is_valid, errors = session.
|
| 291 |
|
| 292 |
if is_valid:
|
| 293 |
-
session.
|
| 294 |
session.update_timestamp()
|
| 295 |
html = """
|
| 296 |
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 297 |
<span style="color: #2e7d32;">✓ Images complete. Proceeding to Observations tab...</span>
|
| 298 |
</div>
|
| 299 |
"""
|
| 300 |
-
return session, html, gr.update(selected=
|
| 301 |
else:
|
| 302 |
-
session.
|
| 303 |
error_items = "".join(f"<li>{e}</li>" for e in errors)
|
| 304 |
html = f"""
|
| 305 |
<div style="background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 10px;">
|
|
@@ -309,18 +296,7 @@ def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int
|
|
| 309 |
</ul>
|
| 310 |
</div>
|
| 311 |
"""
|
| 312 |
-
return session, html, gr.update(selected=
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
def update_room_choices(session: SessionState) -> dict:
|
| 316 |
-
"""Update room dropdown choices.
|
| 317 |
-
|
| 318 |
-
Returns:
|
| 319 |
-
Gradio update dict for Dropdown component.
|
| 320 |
-
"""
|
| 321 |
-
choices = [(r.name, r.id) for r in session.rooms]
|
| 322 |
-
# Don't reset value - let user keep their selection when adding multiple images
|
| 323 |
-
return gr.update(choices=choices)
|
| 324 |
|
| 325 |
|
| 326 |
def load_from_session(session: SessionState) -> tuple[list[tuple], str, str]:
|
|
|
|
| 1 |
+
"""Tab 2: Images.
|
| 2 |
|
| 3 |
Upload and manage fire damage images for AI analysis.
|
| 4 |
+
MVP Simplification: Single room - images auto-assigned to the room.
|
| 5 |
"""
|
| 6 |
|
| 7 |
import uuid
|
| 8 |
import gradio as gr
|
| 9 |
+
from typing import Any
|
| 10 |
from PIL import Image
|
| 11 |
import io
|
| 12 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def create_tab() -> dict[str, Any]:
|
| 19 |
+
"""Create Tab 2 UI components.
|
| 20 |
|
| 21 |
Returns:
|
| 22 |
Dictionary of component references for event wiring.
|
| 23 |
"""
|
| 24 |
gr.Markdown("### Fire Damage Images")
|
| 25 |
gr.Markdown(
|
| 26 |
+
f"*Upload up to {settings.max_images_per_assessment} images for AI analysis.*"
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
with gr.Row():
|
|
|
|
| 34 |
file_types=["image"],
|
| 35 |
elem_id="image_upload",
|
| 36 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
image_description = gr.Textbox(
|
| 38 |
label="Description (optional)",
|
| 39 |
placeholder="e.g., View of ceiling deck from center aisle",
|
|
|
|
| 68 |
with gr.Row():
|
| 69 |
validation_status = gr.HTML(
|
| 70 |
value="",
|
| 71 |
+
elem_id="tab2_validation",
|
| 72 |
)
|
| 73 |
|
| 74 |
# Resume warning (shown when images need re-upload)
|
|
|
|
| 80 |
)
|
| 81 |
|
| 82 |
with gr.Row():
|
| 83 |
+
back_btn = gr.Button("← Back to Room")
|
| 84 |
validate_btn = gr.Button(
|
| 85 |
"Validate & Continue to Observations →",
|
| 86 |
variant="primary",
|
|
|
|
| 88 |
|
| 89 |
return {
|
| 90 |
"image_upload": image_upload,
|
|
|
|
| 91 |
"image_description": image_description,
|
| 92 |
"add_image_btn": add_image_btn,
|
| 93 |
"clear_upload_btn": clear_upload_btn,
|
|
|
|
| 105 |
def add_image(
|
| 106 |
session: SessionState,
|
| 107 |
files: list | None,
|
|
|
|
| 108 |
description: str,
|
| 109 |
+
) -> tuple[SessionState, list[tuple], str, str, None, str]:
|
| 110 |
"""Add one or more images to the session (batch upload).
|
| 111 |
|
| 112 |
+
Images are automatically associated with the single room.
|
| 113 |
+
|
| 114 |
Args:
|
| 115 |
session: Current session state.
|
| 116 |
files: List of uploaded file objects from gr.Files, each with a `name` attribute.
|
|
|
|
| 117 |
description: Optional description applied to all images.
|
| 118 |
|
| 119 |
Returns:
|
| 120 |
Tuple of (session, gallery_data, validation_html, image_count,
|
| 121 |
+
cleared_files, cleared_description).
|
| 122 |
"""
|
| 123 |
validation_html = ""
|
| 124 |
|
|
|
|
| 126 |
errors = []
|
| 127 |
if not files or len(files) == 0:
|
| 128 |
errors.append("Please upload at least one image")
|
|
|
|
|
|
|
| 129 |
|
| 130 |
# Check capacity
|
| 131 |
current_count = len(session.images)
|
|
|
|
| 148 |
"""
|
| 149 |
gallery_data = _get_gallery_data(session)
|
| 150 |
count_str = f"{len(session.images)} / {max_allowed}"
|
| 151 |
+
return session, gallery_data, validation_html, count_str, files, description
|
| 152 |
|
| 153 |
+
# Get room info from session (single room)
|
| 154 |
+
room_id = session.room.id
|
| 155 |
+
room_name = session.room.name.replace(" ", "_")[:20] if session.room.name else "room"
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
# Process each uploaded file
|
| 158 |
added_count = 0
|
|
|
|
| 192 |
if added_count > 0:
|
| 193 |
validation_html = f"""
|
| 194 |
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 195 |
+
<span style="color: #2e7d32;">✓ {added_count} image(s) added for {room_name}</span>
|
| 196 |
</div>
|
| 197 |
"""
|
| 198 |
else:
|
|
|
|
| 206 |
count_str = f"{len(session.images)} / {max_allowed}"
|
| 207 |
|
| 208 |
# Clear form
|
| 209 |
+
return session, gallery_data, validation_html, count_str, None, ""
|
| 210 |
|
| 211 |
|
| 212 |
def remove_last_image(session: SessionState) -> tuple[SessionState, list[tuple], str, str]:
|
|
|
|
| 252 |
|
| 253 |
|
| 254 |
def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int]:
|
| 255 |
+
"""Validate Tab 2 and proceed to Tab 3.
|
| 256 |
|
| 257 |
Returns:
|
| 258 |
Tuple of (session, validation_html, next_tab_index).
|
|
|
|
| 272 |
</p>
|
| 273 |
</div>
|
| 274 |
"""
|
| 275 |
+
return session, html, gr.update(selected=1) # Stay on Images tab (index 1)
|
| 276 |
|
| 277 |
+
is_valid, errors = session.validate_tab2()
|
| 278 |
|
| 279 |
if is_valid:
|
| 280 |
+
session.tab2_complete = True
|
| 281 |
session.update_timestamp()
|
| 282 |
html = """
|
| 283 |
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 284 |
<span style="color: #2e7d32;">✓ Images complete. Proceeding to Observations tab...</span>
|
| 285 |
</div>
|
| 286 |
"""
|
| 287 |
+
return session, html, gr.update(selected=2) # Go to tab index 2 (Observations)
|
| 288 |
else:
|
| 289 |
+
session.tab2_complete = False
|
| 290 |
error_items = "".join(f"<li>{e}</li>" for e in errors)
|
| 291 |
html = f"""
|
| 292 |
<div style="background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 10px;">
|
|
|
|
| 296 |
</ul>
|
| 297 |
</div>
|
| 298 |
"""
|
| 299 |
+
return session, html, gr.update(selected=1) # Stay on current tab
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
|
| 302 |
def load_from_session(session: SessionState) -> tuple[list[tuple], str, str]:
|
ui/tabs/project.py
DELETED
|
@@ -1,287 +0,0 @@
|
|
| 1 |
-
"""Tab 1: Project Information.
|
| 2 |
-
|
| 3 |
-
Collects project details, client information, and facility classification.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import re
|
| 7 |
-
import gradio as gr
|
| 8 |
-
from typing import Any
|
| 9 |
-
|
| 10 |
-
from ui.state import SessionState, ProjectFormData
|
| 11 |
-
from ui.constants import US_STATES, ASSESSOR_CREDENTIALS
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
# ZIP code validation regex (5 digits or 5+4 format)
|
| 15 |
-
ZIP_PATTERN = re.compile(r"^\d{5}(-\d{4})?$")
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
# Map UI values to schema values
|
| 19 |
-
FACILITY_MAP = {
|
| 20 |
-
"Non-Operational": "non-operational",
|
| 21 |
-
"Operational": "operational",
|
| 22 |
-
"Public/Childcare": "public-childcare",
|
| 23 |
-
}
|
| 24 |
-
FACILITY_MAP_REVERSE = {v: k for k, v in FACILITY_MAP.items()}
|
| 25 |
-
|
| 26 |
-
ERA_MAP = {
|
| 27 |
-
"Pre-1980": "pre-1980",
|
| 28 |
-
"1980-2000": "1980-2000",
|
| 29 |
-
"Post-2000": "post-2000",
|
| 30 |
-
}
|
| 31 |
-
ERA_MAP_REVERSE = {v: k for k, v in ERA_MAP.items()}
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def create_tab() -> dict[str, Any]:
|
| 35 |
-
"""Create Tab 1 UI components.
|
| 36 |
-
|
| 37 |
-
Returns:
|
| 38 |
-
Dictionary of component references for event wiring.
|
| 39 |
-
"""
|
| 40 |
-
gr.Markdown("### Project Information")
|
| 41 |
-
gr.Markdown("*Enter project details, client information, and facility classification.*")
|
| 42 |
-
|
| 43 |
-
with gr.Row():
|
| 44 |
-
with gr.Column():
|
| 45 |
-
project_name = gr.Textbox(
|
| 46 |
-
label="Project/Facility Name *",
|
| 47 |
-
placeholder="e.g., ABC Warehouse",
|
| 48 |
-
elem_id="project_name",
|
| 49 |
-
)
|
| 50 |
-
address = gr.Textbox(
|
| 51 |
-
label="Street Address *",
|
| 52 |
-
elem_id="address",
|
| 53 |
-
)
|
| 54 |
-
with gr.Row():
|
| 55 |
-
city = gr.Textbox(label="City *", elem_id="city")
|
| 56 |
-
state = gr.Dropdown(
|
| 57 |
-
label="State *",
|
| 58 |
-
choices=US_STATES,
|
| 59 |
-
elem_id="state",
|
| 60 |
-
allow_custom_value=True, # Allow empty value for validation
|
| 61 |
-
)
|
| 62 |
-
with gr.Column(scale=1):
|
| 63 |
-
zip_code = gr.Textbox(
|
| 64 |
-
label="ZIP Code *",
|
| 65 |
-
max_lines=1,
|
| 66 |
-
elem_id="zip_code",
|
| 67 |
-
info="Format: 12345 or 12345-6789",
|
| 68 |
-
)
|
| 69 |
-
zip_validation = gr.HTML(
|
| 70 |
-
value="",
|
| 71 |
-
elem_id="zip_validation",
|
| 72 |
-
)
|
| 73 |
-
|
| 74 |
-
with gr.Column():
|
| 75 |
-
client_name = gr.Textbox(
|
| 76 |
-
label="Client Name *",
|
| 77 |
-
elem_id="client_name",
|
| 78 |
-
)
|
| 79 |
-
fire_date = gr.DateTime(
|
| 80 |
-
label="Fire Date *",
|
| 81 |
-
include_time=False,
|
| 82 |
-
type="string",
|
| 83 |
-
elem_id="fire_date",
|
| 84 |
-
)
|
| 85 |
-
assessment_date = gr.DateTime(
|
| 86 |
-
label="Assessment Date *",
|
| 87 |
-
include_time=False,
|
| 88 |
-
type="string",
|
| 89 |
-
elem_id="assessment_date",
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
with gr.Row():
|
| 93 |
-
facility_classification = gr.Radio(
|
| 94 |
-
choices=["Non-Operational", "Operational", "Public/Childcare"],
|
| 95 |
-
label="Facility Classification *",
|
| 96 |
-
value="Non-Operational",
|
| 97 |
-
info="Affects clearance thresholds (see FDAM §3.1)",
|
| 98 |
-
elem_id="facility_classification",
|
| 99 |
-
)
|
| 100 |
-
construction_era = gr.Radio(
|
| 101 |
-
choices=["Pre-1980", "1980-2000", "Post-2000"],
|
| 102 |
-
label="Construction Era *",
|
| 103 |
-
value="Post-2000",
|
| 104 |
-
info="Affects LBP/ACM regulatory flags",
|
| 105 |
-
elem_id="construction_era",
|
| 106 |
-
)
|
| 107 |
-
|
| 108 |
-
with gr.Row():
|
| 109 |
-
assessor_name = gr.Textbox(
|
| 110 |
-
label="Assessor Name *",
|
| 111 |
-
elem_id="assessor_name",
|
| 112 |
-
)
|
| 113 |
-
assessor_credentials = gr.Dropdown(
|
| 114 |
-
label="Credentials (optional)",
|
| 115 |
-
choices=ASSESSOR_CREDENTIALS,
|
| 116 |
-
multiselect=True,
|
| 117 |
-
elem_id="assessor_credentials",
|
| 118 |
-
info="Select all that apply",
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
# Validation status display
|
| 122 |
-
with gr.Row():
|
| 123 |
-
validation_status = gr.HTML(
|
| 124 |
-
value="",
|
| 125 |
-
elem_id="tab1_validation",
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
with gr.Row():
|
| 129 |
-
validate_btn = gr.Button(
|
| 130 |
-
"Validate & Continue to Rooms →",
|
| 131 |
-
variant="primary",
|
| 132 |
-
)
|
| 133 |
-
|
| 134 |
-
return {
|
| 135 |
-
"project_name": project_name,
|
| 136 |
-
"address": address,
|
| 137 |
-
"city": city,
|
| 138 |
-
"state": state,
|
| 139 |
-
"zip_code": zip_code,
|
| 140 |
-
"zip_validation": zip_validation,
|
| 141 |
-
"client_name": client_name,
|
| 142 |
-
"fire_date": fire_date,
|
| 143 |
-
"assessment_date": assessment_date,
|
| 144 |
-
"facility_classification": facility_classification,
|
| 145 |
-
"construction_era": construction_era,
|
| 146 |
-
"assessor_name": assessor_name,
|
| 147 |
-
"assessor_credentials": assessor_credentials,
|
| 148 |
-
"validation_status": validation_status,
|
| 149 |
-
"validate_btn": validate_btn,
|
| 150 |
-
}
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
def update_session_from_form(
|
| 154 |
-
session: SessionState,
|
| 155 |
-
project_name: str,
|
| 156 |
-
address: str,
|
| 157 |
-
city: str,
|
| 158 |
-
state: str,
|
| 159 |
-
zip_code: str,
|
| 160 |
-
client_name: str,
|
| 161 |
-
fire_date: str,
|
| 162 |
-
assessment_date: str,
|
| 163 |
-
facility_classification: str,
|
| 164 |
-
construction_era: str,
|
| 165 |
-
assessor_name: str,
|
| 166 |
-
assessor_credentials: list[str] | None,
|
| 167 |
-
) -> SessionState:
|
| 168 |
-
"""Update session state from form values."""
|
| 169 |
-
session.project = ProjectFormData(
|
| 170 |
-
project_name=project_name or "",
|
| 171 |
-
address=address or "",
|
| 172 |
-
city=city or "",
|
| 173 |
-
state=state or "",
|
| 174 |
-
zip_code=zip_code or "",
|
| 175 |
-
client_name=client_name or "",
|
| 176 |
-
fire_date=fire_date or "",
|
| 177 |
-
assessment_date=assessment_date or "",
|
| 178 |
-
facility_classification=FACILITY_MAP.get(facility_classification, "non-operational"),
|
| 179 |
-
construction_era=ERA_MAP.get(construction_era, "post-2000"),
|
| 180 |
-
assessor_name=assessor_name or "",
|
| 181 |
-
assessor_credentials=assessor_credentials or [],
|
| 182 |
-
)
|
| 183 |
-
session.update_timestamp()
|
| 184 |
-
return session
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def validate_and_continue(
|
| 188 |
-
session: SessionState,
|
| 189 |
-
project_name: str,
|
| 190 |
-
address: str,
|
| 191 |
-
city: str,
|
| 192 |
-
state: str,
|
| 193 |
-
zip_code: str,
|
| 194 |
-
client_name: str,
|
| 195 |
-
fire_date: str,
|
| 196 |
-
assessment_date: str,
|
| 197 |
-
facility_classification: str,
|
| 198 |
-
construction_era: str,
|
| 199 |
-
assessor_name: str,
|
| 200 |
-
assessor_credentials: list[str] | None,
|
| 201 |
-
) -> tuple[SessionState, str, int]:
|
| 202 |
-
"""Validate Tab 1 and update session.
|
| 203 |
-
|
| 204 |
-
Returns:
|
| 205 |
-
Tuple of (updated session, validation HTML, next tab index).
|
| 206 |
-
"""
|
| 207 |
-
# Update session first
|
| 208 |
-
session = update_session_from_form(
|
| 209 |
-
session,
|
| 210 |
-
project_name,
|
| 211 |
-
address,
|
| 212 |
-
city,
|
| 213 |
-
state,
|
| 214 |
-
zip_code,
|
| 215 |
-
client_name,
|
| 216 |
-
fire_date,
|
| 217 |
-
assessment_date,
|
| 218 |
-
facility_classification,
|
| 219 |
-
construction_era,
|
| 220 |
-
assessor_name,
|
| 221 |
-
assessor_credentials,
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
# Validate
|
| 225 |
-
is_valid, errors = session.validate_tab1()
|
| 226 |
-
|
| 227 |
-
if is_valid:
|
| 228 |
-
session.tab1_complete = True
|
| 229 |
-
html = """
|
| 230 |
-
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 231 |
-
<span style="color: #2e7d32;">✓ Project information complete. Proceeding to Rooms tab...</span>
|
| 232 |
-
</div>
|
| 233 |
-
"""
|
| 234 |
-
return session, html, gr.update(selected=1) # Go to tab index 1 (Rooms)
|
| 235 |
-
else:
|
| 236 |
-
session.tab1_complete = False
|
| 237 |
-
error_items = "".join(f"<li>{e}</li>" for e in errors)
|
| 238 |
-
html = f"""
|
| 239 |
-
<div style="background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 10px;">
|
| 240 |
-
<strong style="color: #c62828;">Please fix the following:</strong>
|
| 241 |
-
<ul style="margin: 5px 0 0 0; padding-left: 20px; color: #c62828;">
|
| 242 |
-
{error_items}
|
| 243 |
-
</ul>
|
| 244 |
-
</div>
|
| 245 |
-
"""
|
| 246 |
-
return session, html, gr.update(selected=0) # Stay on current tab
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
def load_form_from_session(session: SessionState) -> tuple:
|
| 250 |
-
"""Load form values from session state.
|
| 251 |
-
|
| 252 |
-
Returns:
|
| 253 |
-
Tuple of form values in component order.
|
| 254 |
-
"""
|
| 255 |
-
p = session.project
|
| 256 |
-
return (
|
| 257 |
-
p.project_name,
|
| 258 |
-
p.address,
|
| 259 |
-
p.city,
|
| 260 |
-
p.state,
|
| 261 |
-
p.zip_code,
|
| 262 |
-
p.client_name,
|
| 263 |
-
p.fire_date,
|
| 264 |
-
p.assessment_date,
|
| 265 |
-
FACILITY_MAP_REVERSE.get(p.facility_classification, "Non-Operational"),
|
| 266 |
-
ERA_MAP_REVERSE.get(p.construction_era, "Post-2000"),
|
| 267 |
-
p.assessor_name,
|
| 268 |
-
p.assessor_credentials,
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
def validate_zip_format(zip_code: str) -> str:
|
| 273 |
-
"""Validate ZIP code format and return validation HTML indicator.
|
| 274 |
-
|
| 275 |
-
Args:
|
| 276 |
-
zip_code: The ZIP code string to validate.
|
| 277 |
-
|
| 278 |
-
Returns:
|
| 279 |
-
HTML string with validation indicator (green check or red X).
|
| 280 |
-
"""
|
| 281 |
-
if not zip_code:
|
| 282 |
-
return "" # Empty - no indicator
|
| 283 |
-
|
| 284 |
-
if ZIP_PATTERN.match(zip_code.strip()):
|
| 285 |
-
return '<span style="color: #2e7d32; font-size: 12px;">✓ Valid format</span>'
|
| 286 |
-
else:
|
| 287 |
-
return '<span style="color: #c62828; font-size: 12px;">✗ Use format: 12345 or 12345-6789</span>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui/tabs/results.py
CHANGED
|
@@ -141,8 +141,7 @@ def check_preflight(session: SessionState) -> str:
|
|
| 141 |
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 15px;">
|
| 142 |
<strong style="color: #2e7d32;">✓ Ready to Generate</strong>
|
| 143 |
<div style="margin-top: 10px; color: #333;">
|
| 144 |
-
<strong>
|
| 145 |
-
<strong>Rooms:</strong> {stats['rooms']}<br>
|
| 146 |
<strong>Images:</strong> {stats['images']}<br>
|
| 147 |
<strong>Total Area:</strong> {stats['total_floor_area_sf']} SF<br>
|
| 148 |
<strong>Facility:</strong> {stats['facility_classification']}<br>
|
|
@@ -214,11 +213,12 @@ def generate_assessment(
|
|
| 214 |
try:
|
| 215 |
if sow_markdown:
|
| 216 |
# Save Markdown file
|
|
|
|
| 217 |
with tempfile.NamedTemporaryFile(
|
| 218 |
mode='w',
|
| 219 |
suffix='.md',
|
| 220 |
delete=False,
|
| 221 |
-
prefix=f"SOW_{
|
| 222 |
) as f:
|
| 223 |
f.write(sow_markdown)
|
| 224 |
md_path = f.name
|
|
@@ -258,18 +258,12 @@ def _generate_sow_markdown(
|
|
| 258 |
) -> str:
|
| 259 |
"""Generate Scope of Work markdown document.
|
| 260 |
|
| 261 |
-
This is a placeholder - real implementation
|
|
|
|
| 262 |
"""
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
area = room.length_ft * room.width_ft
|
| 267 |
-
volume = area * room.ceiling_height_ft
|
| 268 |
-
room_lines.append(
|
| 269 |
-
f"| {room.name} | {room.length_ft:.0f}' x {room.width_ft:.0f}' x {room.ceiling_height_ft:.0f}' | "
|
| 270 |
-
f"{area:,.0f} | {volume:,.0f} |"
|
| 271 |
-
)
|
| 272 |
-
room_table = "\n".join(room_lines)
|
| 273 |
|
| 274 |
# Build vision summary
|
| 275 |
vision_lines = []
|
|
@@ -302,18 +296,13 @@ def _generate_sow_markdown(
|
|
| 302 |
|
| 303 |
markdown = f"""# Cleaning Specification / Scope of Work
|
| 304 |
|
| 305 |
-
##
|
| 306 |
|
| 307 |
| Field | Value |
|
| 308 |
|-------|-------|
|
| 309 |
-
| **
|
| 310 |
-
| **
|
| 311 |
-
| **
|
| 312 |
-
| **Fire Date** | {session.project.fire_date} |
|
| 313 |
-
| **Assessment Date** | {session.project.assessment_date} |
|
| 314 |
-
| **Facility Classification** | {session.project.facility_classification} |
|
| 315 |
-
| **Construction Era** | {session.project.construction_era} |
|
| 316 |
-
| **Assessor** | {session.project.assessor_name} {session.project.assessor_credentials or ''} |
|
| 317 |
|
| 318 |
---
|
| 319 |
|
|
@@ -321,18 +310,21 @@ def _generate_sow_markdown(
|
|
| 321 |
|
| 322 |
| Metric | Value |
|
| 323 |
|--------|-------|
|
| 324 |
-
|
|
| 325 |
| Total Floor Area | {stats['total_floor_area_sf']} SF |
|
| 326 |
| Total Volume | {stats['total_volume_cf']} CF |
|
| 327 |
| Images Analyzed | {stats['total_images']} |
|
| 328 |
|
| 329 |
---
|
| 330 |
|
| 331 |
-
## Room
|
| 332 |
|
| 333 |
-
|
|
| 334 |
-
|
| 335 |
-
{
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
---
|
| 338 |
|
|
|
|
| 141 |
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 15px;">
|
| 142 |
<strong style="color: #2e7d32;">✓ Ready to Generate</strong>
|
| 143 |
<div style="margin-top: 10px; color: #333;">
|
| 144 |
+
<strong>Room:</strong> {stats['room_name']}<br>
|
|
|
|
| 145 |
<strong>Images:</strong> {stats['images']}<br>
|
| 146 |
<strong>Total Area:</strong> {stats['total_floor_area_sf']} SF<br>
|
| 147 |
<strong>Facility:</strong> {stats['facility_classification']}<br>
|
|
|
|
| 213 |
try:
|
| 214 |
if sow_markdown:
|
| 215 |
# Save Markdown file
|
| 216 |
+
room_name_safe = session.room.name.replace(' ', '_') if session.room.name else "Room"
|
| 217 |
with tempfile.NamedTemporaryFile(
|
| 218 |
mode='w',
|
| 219 |
suffix='.md',
|
| 220 |
delete=False,
|
| 221 |
+
prefix=f"SOW_{room_name_safe}_",
|
| 222 |
) as f:
|
| 223 |
f.write(sow_markdown)
|
| 224 |
md_path = f.name
|
|
|
|
| 258 |
) -> str:
|
| 259 |
"""Generate Scope of Work markdown document.
|
| 260 |
|
| 261 |
+
This is a placeholder - real implementation uses DocumentGenerator.
|
| 262 |
+
Kept for backwards compatibility but should not be called directly.
|
| 263 |
"""
|
| 264 |
+
r = session.room
|
| 265 |
+
area = r.length_ft * r.width_ft
|
| 266 |
+
volume = area * r.ceiling_height_ft
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
# Build vision summary
|
| 269 |
vision_lines = []
|
|
|
|
| 296 |
|
| 297 |
markdown = f"""# Cleaning Specification / Scope of Work
|
| 298 |
|
| 299 |
+
## Room Information
|
| 300 |
|
| 301 |
| Field | Value |
|
| 302 |
|-------|-------|
|
| 303 |
+
| **Room Name** | {r.name} |
|
| 304 |
+
| **Facility Classification** | {r.facility_classification or 'Not specified'} |
|
| 305 |
+
| **Construction Era** | {r.construction_era or 'Not specified'} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
---
|
| 308 |
|
|
|
|
| 310 |
|
| 311 |
| Metric | Value |
|
| 312 |
|--------|-------|
|
| 313 |
+
| Room | {r.name} |
|
| 314 |
| Total Floor Area | {stats['total_floor_area_sf']} SF |
|
| 315 |
| Total Volume | {stats['total_volume_cf']} CF |
|
| 316 |
| Images Analyzed | {stats['total_images']} |
|
| 317 |
|
| 318 |
---
|
| 319 |
|
| 320 |
+
## Room Details
|
| 321 |
|
| 322 |
+
| Property | Value |
|
| 323 |
+
|----------|-------|
|
| 324 |
+
| **Room Name** | {r.name} |
|
| 325 |
+
| **Dimensions** | {r.length_ft:.0f}' x {r.width_ft:.0f}' x {r.ceiling_height_ft:.0f}' |
|
| 326 |
+
| **Floor Area** | {area:,.0f} SF |
|
| 327 |
+
| **Volume** | {volume:,.0f} CF |
|
| 328 |
|
| 329 |
---
|
| 330 |
|
ui/tabs/room.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tab 1: Room Assessment.
|
| 2 |
+
|
| 3 |
+
Single room input with dimensions and assessment context.
|
| 4 |
+
MVP Simplification: No multi-room support.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from ui.state import SessionState
|
| 11 |
+
from ui.constants import CEILING_HEIGHT_PRESETS
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Facility classification options
|
| 15 |
+
FACILITY_OPTIONS = [
|
| 16 |
+
("Operational", "operational"),
|
| 17 |
+
("Non-Operational", "non-operational"),
|
| 18 |
+
("Public/Childcare", "public-childcare"),
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
# Construction era options
|
| 22 |
+
CONSTRUCTION_ERA_OPTIONS = [
|
| 23 |
+
("Pre-1980 (potential LBP/ACM)", "pre-1980"),
|
| 24 |
+
("1980-2000", "1980-2000"),
|
| 25 |
+
("Post-2000", "post-2000"),
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def create_tab() -> dict[str, Any]:
|
| 30 |
+
"""Create Tab 1 UI components.
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Dictionary of component references for event wiring.
|
| 34 |
+
"""
|
| 35 |
+
gr.Markdown("### Room Assessment")
|
| 36 |
+
gr.Markdown("*Enter room details and assessment context.*")
|
| 37 |
+
|
| 38 |
+
# Room identification
|
| 39 |
+
room_name = gr.Textbox(
|
| 40 |
+
label="Room/Area Name *",
|
| 41 |
+
placeholder="e.g., Warehouse Bay A, Office 101",
|
| 42 |
+
elem_id="room_name",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Dimensions
|
| 46 |
+
gr.Markdown("#### Dimensions")
|
| 47 |
+
with gr.Row():
|
| 48 |
+
room_length = gr.Number(
|
| 49 |
+
label="Length (ft) *",
|
| 50 |
+
minimum=1,
|
| 51 |
+
value=None,
|
| 52 |
+
elem_id="room_length",
|
| 53 |
+
)
|
| 54 |
+
room_width = gr.Number(
|
| 55 |
+
label="Width (ft) *",
|
| 56 |
+
minimum=1,
|
| 57 |
+
value=None,
|
| 58 |
+
elem_id="room_width",
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
with gr.Row():
|
| 62 |
+
room_height_preset = gr.Dropdown(
|
| 63 |
+
label="Ceiling Height *",
|
| 64 |
+
choices=CEILING_HEIGHT_PRESETS,
|
| 65 |
+
elem_id="room_height_preset",
|
| 66 |
+
info="Select preset or choose Custom",
|
| 67 |
+
)
|
| 68 |
+
room_height_custom = gr.Number(
|
| 69 |
+
label="Custom Height (ft)",
|
| 70 |
+
minimum=1,
|
| 71 |
+
value=None,
|
| 72 |
+
visible=False,
|
| 73 |
+
elem_id="room_height_custom",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Calculated displays
|
| 77 |
+
with gr.Row():
|
| 78 |
+
floor_area = gr.Textbox(
|
| 79 |
+
label="Floor Area (SF)",
|
| 80 |
+
value="0",
|
| 81 |
+
interactive=False,
|
| 82 |
+
)
|
| 83 |
+
room_volume = gr.Textbox(
|
| 84 |
+
label="Volume (CF)",
|
| 85 |
+
value="0",
|
| 86 |
+
interactive=False,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Assessment context (moved from Project tab)
|
| 90 |
+
gr.Markdown("#### Assessment Context")
|
| 91 |
+
|
| 92 |
+
facility_classification = gr.Radio(
|
| 93 |
+
label="Facility Classification *",
|
| 94 |
+
choices=FACILITY_OPTIONS,
|
| 95 |
+
value="non-operational",
|
| 96 |
+
elem_id="facility_classification",
|
| 97 |
+
info="Affects clearance thresholds",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
construction_era = gr.Radio(
|
| 101 |
+
label="Construction Era *",
|
| 102 |
+
choices=CONSTRUCTION_ERA_OPTIONS,
|
| 103 |
+
value="post-2000",
|
| 104 |
+
elem_id="construction_era",
|
| 105 |
+
info="Pre-1980 triggers LBP/ACM flags",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
# Validation status
|
| 109 |
+
validation_status = gr.HTML(
|
| 110 |
+
value="",
|
| 111 |
+
elem_id="tab1_validation",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
validate_btn = gr.Button(
|
| 115 |
+
"Continue to Images →",
|
| 116 |
+
variant="primary",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
"room_name": room_name,
|
| 121 |
+
"room_length": room_length,
|
| 122 |
+
"room_width": room_width,
|
| 123 |
+
"room_height_preset": room_height_preset,
|
| 124 |
+
"room_height_custom": room_height_custom,
|
| 125 |
+
"floor_area": floor_area,
|
| 126 |
+
"room_volume": room_volume,
|
| 127 |
+
"facility_classification": facility_classification,
|
| 128 |
+
"construction_era": construction_era,
|
| 129 |
+
"validation_status": validation_status,
|
| 130 |
+
"validate_btn": validate_btn,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def on_height_preset_change(preset_value: int | None) -> dict:
|
| 135 |
+
"""Show/hide custom height input based on preset selection.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
preset_value: The selected preset value, or None for "Custom".
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
Gradio update dict for custom height visibility.
|
| 142 |
+
"""
|
| 143 |
+
return gr.update(visible=(preset_value is None))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def update_calculated_values(
|
| 147 |
+
length: float | None,
|
| 148 |
+
width: float | None,
|
| 149 |
+
height_preset: int | None,
|
| 150 |
+
height_custom: float | None,
|
| 151 |
+
) -> tuple[str, str]:
|
| 152 |
+
"""Calculate and return floor area and volume.
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
Tuple of (floor_area_str, volume_str).
|
| 156 |
+
"""
|
| 157 |
+
# Get effective values
|
| 158 |
+
length_val = float(length) if length and length > 0 else 0
|
| 159 |
+
width_val = float(width) if width and width > 0 else 0
|
| 160 |
+
|
| 161 |
+
if height_preset is not None:
|
| 162 |
+
height_val = float(height_preset)
|
| 163 |
+
elif height_custom is not None and height_custom > 0:
|
| 164 |
+
height_val = float(height_custom)
|
| 165 |
+
else:
|
| 166 |
+
height_val = 0
|
| 167 |
+
|
| 168 |
+
# Calculate
|
| 169 |
+
area = length_val * width_val
|
| 170 |
+
volume = area * height_val
|
| 171 |
+
|
| 172 |
+
return f"{area:,.0f}", f"{volume:,.0f}"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def save_room_to_session(
|
| 176 |
+
session: SessionState,
|
| 177 |
+
name: str,
|
| 178 |
+
length: float | None,
|
| 179 |
+
width: float | None,
|
| 180 |
+
height_preset: int | None,
|
| 181 |
+
height_custom: float | None,
|
| 182 |
+
facility_classification: str,
|
| 183 |
+
construction_era: str,
|
| 184 |
+
) -> SessionState:
|
| 185 |
+
"""Save room data to session (called on field changes).
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
Updated session.
|
| 189 |
+
"""
|
| 190 |
+
# Determine actual ceiling height
|
| 191 |
+
if height_preset is not None:
|
| 192 |
+
height = float(height_preset)
|
| 193 |
+
elif height_custom is not None and height_custom > 0:
|
| 194 |
+
height = float(height_custom)
|
| 195 |
+
else:
|
| 196 |
+
height = 0
|
| 197 |
+
|
| 198 |
+
# Update session room data
|
| 199 |
+
session.room.name = name.strip() if name else ""
|
| 200 |
+
session.room.length_ft = float(length) if length and length > 0 else 0
|
| 201 |
+
session.room.width_ft = float(width) if width and width > 0 else 0
|
| 202 |
+
session.room.ceiling_height_ft = height
|
| 203 |
+
session.room.facility_classification = facility_classification
|
| 204 |
+
session.room.construction_era = construction_era
|
| 205 |
+
session.update_timestamp()
|
| 206 |
+
|
| 207 |
+
return session
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int]:
|
| 211 |
+
"""Validate Tab 1 and proceed to Tab 2.
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
Tuple of (session, validation_html, next_tab_index).
|
| 215 |
+
"""
|
| 216 |
+
is_valid, errors = session.validate_tab1()
|
| 217 |
+
|
| 218 |
+
if is_valid:
|
| 219 |
+
session.tab1_complete = True
|
| 220 |
+
session.update_timestamp()
|
| 221 |
+
html = """
|
| 222 |
+
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 223 |
+
<span style="color: #2e7d32;">✓ Room details complete. Proceeding to Images tab...</span>
|
| 224 |
+
</div>
|
| 225 |
+
"""
|
| 226 |
+
return session, html, gr.update(selected=1) # Go to tab index 1 (Images)
|
| 227 |
+
else:
|
| 228 |
+
session.tab1_complete = False
|
| 229 |
+
error_items = "".join(f"<li>{e}</li>" for e in errors)
|
| 230 |
+
html = f"""
|
| 231 |
+
<div style="background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 10px;">
|
| 232 |
+
<strong style="color: #c62828;">Please fix the following:</strong>
|
| 233 |
+
<ul style="margin: 5px 0 0 0; padding-left: 20px; color: #c62828;">
|
| 234 |
+
{error_items}
|
| 235 |
+
</ul>
|
| 236 |
+
</div>
|
| 237 |
+
"""
|
| 238 |
+
return session, html, gr.update(selected=0) # Stay on current tab
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def load_from_session(session: SessionState) -> tuple[str, float | None, float | None, int | None, float | None, str, str, str, str]:
|
| 242 |
+
"""Load room data from session.
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
Tuple of (name, length, width, height_preset, height_custom,
|
| 246 |
+
floor_area, volume, facility_classification, construction_era).
|
| 247 |
+
"""
|
| 248 |
+
r = session.room
|
| 249 |
+
|
| 250 |
+
# Determine if height matches a preset or is custom
|
| 251 |
+
height_preset = None
|
| 252 |
+
height_custom = None
|
| 253 |
+
|
| 254 |
+
# Check if ceiling height matches a preset value
|
| 255 |
+
preset_values = [p[1] for p in CEILING_HEIGHT_PRESETS if p[1] is not None]
|
| 256 |
+
if r.ceiling_height_ft in preset_values:
|
| 257 |
+
height_preset = int(r.ceiling_height_ft)
|
| 258 |
+
elif r.ceiling_height_ft > 0:
|
| 259 |
+
height_custom = r.ceiling_height_ft
|
| 260 |
+
|
| 261 |
+
# Calculate stats
|
| 262 |
+
area = r.length_ft * r.width_ft
|
| 263 |
+
volume = area * r.ceiling_height_ft
|
| 264 |
+
|
| 265 |
+
return (
|
| 266 |
+
r.name,
|
| 267 |
+
r.length_ft if r.length_ft > 0 else None,
|
| 268 |
+
r.width_ft if r.width_ft > 0 else None,
|
| 269 |
+
height_preset,
|
| 270 |
+
height_custom,
|
| 271 |
+
f"{area:,.0f}",
|
| 272 |
+
f"{volume:,.0f}",
|
| 273 |
+
r.facility_classification,
|
| 274 |
+
r.construction_era,
|
| 275 |
+
)
|
ui/tabs/rooms.py
DELETED
|
@@ -1,344 +0,0 @@
|
|
| 1 |
-
"""Tab 2: Building/Rooms.
|
| 2 |
-
|
| 3 |
-
Manages rooms and areas for assessment with dimensions.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import uuid
|
| 7 |
-
import gradio as gr
|
| 8 |
-
from typing import Any
|
| 9 |
-
|
| 10 |
-
from ui.state import SessionState, RoomFormData
|
| 11 |
-
from ui.components import create_room_table_data
|
| 12 |
-
from ui.constants import FLOOR_OPTIONS, CEILING_HEIGHT_PRESETS
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def create_tab() -> dict[str, Any]:
|
| 16 |
-
"""Create Tab 2 UI components.
|
| 17 |
-
|
| 18 |
-
Returns:
|
| 19 |
-
Dictionary of component references for event wiring.
|
| 20 |
-
"""
|
| 21 |
-
gr.Markdown("### Building/Rooms")
|
| 22 |
-
gr.Markdown("*Add rooms and areas to assess. At least one room is required.*")
|
| 23 |
-
|
| 24 |
-
with gr.Row():
|
| 25 |
-
with gr.Column(scale=2):
|
| 26 |
-
room_name = gr.Textbox(
|
| 27 |
-
label="Room/Area Name *",
|
| 28 |
-
placeholder="e.g., Warehouse Bay A",
|
| 29 |
-
elem_id="room_name",
|
| 30 |
-
)
|
| 31 |
-
room_floor = gr.Dropdown(
|
| 32 |
-
label="Floor (optional)",
|
| 33 |
-
choices=FLOOR_OPTIONS,
|
| 34 |
-
elem_id="room_floor",
|
| 35 |
-
)
|
| 36 |
-
with gr.Row():
|
| 37 |
-
room_length = gr.Number(
|
| 38 |
-
label="Length (ft) *",
|
| 39 |
-
minimum=1,
|
| 40 |
-
value=None,
|
| 41 |
-
elem_id="room_length",
|
| 42 |
-
)
|
| 43 |
-
room_width = gr.Number(
|
| 44 |
-
label="Width (ft) *",
|
| 45 |
-
minimum=1,
|
| 46 |
-
value=None,
|
| 47 |
-
elem_id="room_width",
|
| 48 |
-
)
|
| 49 |
-
with gr.Row():
|
| 50 |
-
room_height_preset = gr.Dropdown(
|
| 51 |
-
label="Ceiling Height *",
|
| 52 |
-
choices=CEILING_HEIGHT_PRESETS,
|
| 53 |
-
elem_id="room_height_preset",
|
| 54 |
-
info="Select preset or choose Custom",
|
| 55 |
-
)
|
| 56 |
-
room_height_custom = gr.Number(
|
| 57 |
-
label="Custom Height (ft)",
|
| 58 |
-
minimum=1,
|
| 59 |
-
value=None,
|
| 60 |
-
visible=False,
|
| 61 |
-
elem_id="room_height_custom",
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
with gr.Row():
|
| 65 |
-
add_room_btn = gr.Button("Add Room", variant="primary")
|
| 66 |
-
clear_form_btn = gr.Button("Clear Form", variant="secondary")
|
| 67 |
-
|
| 68 |
-
with gr.Column(scale=3):
|
| 69 |
-
rooms_table = gr.Dataframe(
|
| 70 |
-
headers=["Name", "L x W x H", "Area (SF)", "Volume (CF)"],
|
| 71 |
-
label="Rooms Added",
|
| 72 |
-
interactive=False,
|
| 73 |
-
elem_id="rooms_table",
|
| 74 |
-
)
|
| 75 |
-
with gr.Row():
|
| 76 |
-
remove_last_btn = gr.Button("Remove Last Room", variant="secondary")
|
| 77 |
-
clear_all_btn = gr.Button("Clear All Rooms", variant="stop")
|
| 78 |
-
|
| 79 |
-
# Summary stats
|
| 80 |
-
with gr.Row():
|
| 81 |
-
room_count = gr.Textbox(
|
| 82 |
-
label="Total Rooms",
|
| 83 |
-
value="0",
|
| 84 |
-
interactive=False,
|
| 85 |
-
)
|
| 86 |
-
total_area = gr.Textbox(
|
| 87 |
-
label="Total Floor Area (SF)",
|
| 88 |
-
value="0",
|
| 89 |
-
interactive=False,
|
| 90 |
-
)
|
| 91 |
-
total_volume = gr.Textbox(
|
| 92 |
-
label="Total Volume (CF)",
|
| 93 |
-
value="0",
|
| 94 |
-
interactive=False,
|
| 95 |
-
)
|
| 96 |
-
|
| 97 |
-
# Validation status
|
| 98 |
-
with gr.Row():
|
| 99 |
-
validation_status = gr.HTML(
|
| 100 |
-
value="",
|
| 101 |
-
elem_id="tab2_validation",
|
| 102 |
-
)
|
| 103 |
-
|
| 104 |
-
with gr.Row():
|
| 105 |
-
back_btn = gr.Button("← Back to Project Info")
|
| 106 |
-
validate_btn = gr.Button(
|
| 107 |
-
"Validate & Continue to Images →",
|
| 108 |
-
variant="primary",
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
return {
|
| 112 |
-
"room_name": room_name,
|
| 113 |
-
"room_floor": room_floor,
|
| 114 |
-
"room_length": room_length,
|
| 115 |
-
"room_width": room_width,
|
| 116 |
-
"room_height_preset": room_height_preset,
|
| 117 |
-
"room_height_custom": room_height_custom,
|
| 118 |
-
"add_room_btn": add_room_btn,
|
| 119 |
-
"clear_form_btn": clear_form_btn,
|
| 120 |
-
"rooms_table": rooms_table,
|
| 121 |
-
"remove_last_btn": remove_last_btn,
|
| 122 |
-
"clear_all_btn": clear_all_btn,
|
| 123 |
-
"room_count": room_count,
|
| 124 |
-
"total_area": total_area,
|
| 125 |
-
"total_volume": total_volume,
|
| 126 |
-
"validation_status": validation_status,
|
| 127 |
-
"back_btn": back_btn,
|
| 128 |
-
"validate_btn": validate_btn,
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def add_room(
|
| 133 |
-
session: SessionState,
|
| 134 |
-
name: str,
|
| 135 |
-
floor: str | None,
|
| 136 |
-
length: float,
|
| 137 |
-
width: float,
|
| 138 |
-
height_preset: int | None,
|
| 139 |
-
height_custom: float | None,
|
| 140 |
-
) -> tuple[SessionState, list[list], str, str, str, str, str | None, float | None, float | None, None, None]:
|
| 141 |
-
"""Add a room to the session.
|
| 142 |
-
|
| 143 |
-
Returns:
|
| 144 |
-
Tuple of (session, table_data, validation_html, room_count, total_area, total_volume,
|
| 145 |
-
cleared_name, cleared_floor, cleared_length, cleared_width,
|
| 146 |
-
cleared_height_preset, cleared_height_custom).
|
| 147 |
-
"""
|
| 148 |
-
validation_html = ""
|
| 149 |
-
|
| 150 |
-
# Determine actual ceiling height from preset or custom
|
| 151 |
-
if height_preset is not None:
|
| 152 |
-
height = float(height_preset)
|
| 153 |
-
elif height_custom is not None and height_custom > 0:
|
| 154 |
-
height = float(height_custom)
|
| 155 |
-
else:
|
| 156 |
-
height = None
|
| 157 |
-
|
| 158 |
-
# Validate input
|
| 159 |
-
errors = []
|
| 160 |
-
if not name or not name.strip():
|
| 161 |
-
errors.append("Room name is required")
|
| 162 |
-
if not length or length <= 0:
|
| 163 |
-
errors.append("Length must be greater than 0")
|
| 164 |
-
if not width or width <= 0:
|
| 165 |
-
errors.append("Width must be greater than 0")
|
| 166 |
-
if not height or height <= 0:
|
| 167 |
-
errors.append("Ceiling height is required (select preset or enter custom)")
|
| 168 |
-
|
| 169 |
-
if errors:
|
| 170 |
-
error_items = "".join(f"<li>{e}</li>" for e in errors)
|
| 171 |
-
validation_html = f"""
|
| 172 |
-
<div style="background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 10px;">
|
| 173 |
-
<ul style="margin: 0; padding-left: 20px; color: #c62828;">
|
| 174 |
-
{error_items}
|
| 175 |
-
</ul>
|
| 176 |
-
</div>
|
| 177 |
-
"""
|
| 178 |
-
# Return current state without changes
|
| 179 |
-
table_data = create_room_table_data(session)
|
| 180 |
-
stats = _calculate_stats(session)
|
| 181 |
-
return (
|
| 182 |
-
session,
|
| 183 |
-
table_data,
|
| 184 |
-
validation_html,
|
| 185 |
-
stats["count"],
|
| 186 |
-
stats["area"],
|
| 187 |
-
stats["volume"],
|
| 188 |
-
name or "",
|
| 189 |
-
floor,
|
| 190 |
-
length,
|
| 191 |
-
width,
|
| 192 |
-
height_preset,
|
| 193 |
-
height_custom,
|
| 194 |
-
)
|
| 195 |
-
|
| 196 |
-
# Add the room
|
| 197 |
-
room = RoomFormData(
|
| 198 |
-
id=f"room-{uuid.uuid4().hex[:8]}",
|
| 199 |
-
name=name.strip(),
|
| 200 |
-
floor=floor.strip() if floor else "",
|
| 201 |
-
length_ft=float(length),
|
| 202 |
-
width_ft=float(width),
|
| 203 |
-
ceiling_height_ft=float(height),
|
| 204 |
-
)
|
| 205 |
-
session.rooms.append(room)
|
| 206 |
-
session.update_timestamp()
|
| 207 |
-
|
| 208 |
-
# Success message
|
| 209 |
-
validation_html = f"""
|
| 210 |
-
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 211 |
-
<span style="color: #2e7d32;">✓ Added room: {name}</span>
|
| 212 |
-
</div>
|
| 213 |
-
"""
|
| 214 |
-
|
| 215 |
-
table_data = create_room_table_data(session)
|
| 216 |
-
stats = _calculate_stats(session)
|
| 217 |
-
|
| 218 |
-
# Clear form fields (return None for Number components, None for dropdowns)
|
| 219 |
-
return (
|
| 220 |
-
session,
|
| 221 |
-
table_data,
|
| 222 |
-
validation_html,
|
| 223 |
-
stats["count"],
|
| 224 |
-
stats["area"],
|
| 225 |
-
stats["volume"],
|
| 226 |
-
"", # Clear name
|
| 227 |
-
None, # Clear floor dropdown
|
| 228 |
-
None, # Clear length
|
| 229 |
-
None, # Clear width
|
| 230 |
-
None, # Clear height preset
|
| 231 |
-
None, # Clear height custom
|
| 232 |
-
)
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def on_height_preset_change(preset_value: int | None) -> dict:
|
| 236 |
-
"""Show/hide custom height input based on preset selection.
|
| 237 |
-
|
| 238 |
-
Args:
|
| 239 |
-
preset_value: The selected preset value, or None for "Custom".
|
| 240 |
-
|
| 241 |
-
Returns:
|
| 242 |
-
Gradio update dict for custom height visibility.
|
| 243 |
-
"""
|
| 244 |
-
# If None (Custom selected), show custom input; otherwise hide it
|
| 245 |
-
return gr.update(visible=(preset_value is None))
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
def remove_last_room(session: SessionState) -> tuple[SessionState, list[list], str, str, str]:
|
| 249 |
-
"""Remove the last room from the session."""
|
| 250 |
-
if session.rooms:
|
| 251 |
-
removed = session.rooms.pop()
|
| 252 |
-
session.update_timestamp()
|
| 253 |
-
validation_html = f"""
|
| 254 |
-
<div style="background: #fff3e0; border: 1px solid #ffb74d; border-radius: 4px; padding: 10px;">
|
| 255 |
-
<span style="color: #e65100;">Removed room: {removed.name}</span>
|
| 256 |
-
</div>
|
| 257 |
-
"""
|
| 258 |
-
else:
|
| 259 |
-
validation_html = ""
|
| 260 |
-
|
| 261 |
-
table_data = create_room_table_data(session)
|
| 262 |
-
stats = _calculate_stats(session)
|
| 263 |
-
return session, table_data, validation_html, stats["count"], stats["area"], stats["volume"]
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
def clear_all_rooms(session: SessionState) -> tuple[SessionState, list[list], str, str, str, str]:
|
| 267 |
-
"""Clear all rooms from the session."""
|
| 268 |
-
count = len(session.rooms)
|
| 269 |
-
session.rooms = []
|
| 270 |
-
session.update_timestamp()
|
| 271 |
-
|
| 272 |
-
validation_html = ""
|
| 273 |
-
if count > 0:
|
| 274 |
-
validation_html = f"""
|
| 275 |
-
<div style="background: #fff3e0; border: 1px solid #ffb74d; border-radius: 4px; padding: 10px;">
|
| 276 |
-
<span style="color: #e65100;">Cleared {count} room(s)</span>
|
| 277 |
-
</div>
|
| 278 |
-
"""
|
| 279 |
-
|
| 280 |
-
return session, [], validation_html, "0", "0", "0"
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
def validate_and_continue(session: SessionState) -> tuple[SessionState, str, int]:
|
| 284 |
-
"""Validate Tab 2 and proceed to Tab 3.
|
| 285 |
-
|
| 286 |
-
Returns:
|
| 287 |
-
Tuple of (session, validation_html, next_tab_index).
|
| 288 |
-
"""
|
| 289 |
-
is_valid, errors = session.validate_tab2()
|
| 290 |
-
|
| 291 |
-
if is_valid:
|
| 292 |
-
session.tab2_complete = True
|
| 293 |
-
session.update_timestamp()
|
| 294 |
-
html = """
|
| 295 |
-
<div style="background: #e8f5e9; border: 1px solid #66bb6a; border-radius: 4px; padding: 10px;">
|
| 296 |
-
<span style="color: #2e7d32;">✓ Rooms complete. Proceeding to Images tab...</span>
|
| 297 |
-
</div>
|
| 298 |
-
"""
|
| 299 |
-
return session, html, gr.update(selected=2) # Go to tab index 2 (Images)
|
| 300 |
-
else:
|
| 301 |
-
session.tab2_complete = False
|
| 302 |
-
error_items = "".join(f"<li>{e}</li>" for e in errors)
|
| 303 |
-
html = f"""
|
| 304 |
-
<div style="background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 10px;">
|
| 305 |
-
<strong style="color: #c62828;">Please fix the following:</strong>
|
| 306 |
-
<ul style="margin: 5px 0 0 0; padding-left: 20px; color: #c62828;">
|
| 307 |
-
{error_items}
|
| 308 |
-
</ul>
|
| 309 |
-
</div>
|
| 310 |
-
"""
|
| 311 |
-
return session, html, gr.update(selected=1) # Stay on current tab
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
def load_from_session(session: SessionState) -> tuple[list[list], str, str, str]:
|
| 315 |
-
"""Load table data and stats from session.
|
| 316 |
-
|
| 317 |
-
Returns:
|
| 318 |
-
Tuple of (table_data, room_count, total_area, total_volume).
|
| 319 |
-
"""
|
| 320 |
-
table_data = create_room_table_data(session)
|
| 321 |
-
stats = _calculate_stats(session)
|
| 322 |
-
return table_data, stats["count"], stats["area"], stats["volume"]
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
def _calculate_stats(session: SessionState) -> dict[str, str]:
|
| 326 |
-
"""Calculate room statistics."""
|
| 327 |
-
count = len(session.rooms)
|
| 328 |
-
total_area = sum(r.length_ft * r.width_ft for r in session.rooms)
|
| 329 |
-
total_volume = sum(r.length_ft * r.width_ft * r.ceiling_height_ft for r in session.rooms)
|
| 330 |
-
|
| 331 |
-
return {
|
| 332 |
-
"count": str(count),
|
| 333 |
-
"area": f"{total_area:,.0f}",
|
| 334 |
-
"volume": f"{total_volume:,.0f}",
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
def get_room_choices(session: SessionState) -> list[tuple[str, str]]:
|
| 339 |
-
"""Get room choices for dropdown (used in Images tab).
|
| 340 |
-
|
| 341 |
-
Returns:
|
| 342 |
-
List of (display_name, room_id) tuples.
|
| 343 |
-
"""
|
| 344 |
-
return [(r.name, r.id) for r in session.rooms]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|