KinetoLabs Claude Opus 4.5 commited on
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 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
- | State | `gr.Dropdown` | 50 US states + DC + territories |
41
- | Dates | `gr.DateTime` | Calendar picker, no time selection |
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
- | Image Upload | `gr.Files(file_count="multiple")` | Batch upload support |
 
 
47
 
48
  **Keyboard Shortcuts:**
49
- - `Ctrl+1` through `Ctrl+5`: Navigate between tabs
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
- └── tests/
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 project, rooms, images, observations, results
21
  from ui import samples
22
 
23
 
24
- # Keyboard shortcuts JavaScript (Ctrl+1-5 for tab navigation)
25
  KEYBOARD_JS = """
26
  <script>
27
  document.addEventListener('keydown', (e) => {
28
- if (e.ctrlKey && e.key >= '1' && e.key <= '5') {
29
  e.preventDefault();
30
  const tabIds = [
31
- 'tab-project-button', 'tab-rooms-button', 'tab-images-button',
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 project information to generate a professional
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: Project Information
109
- tab_project = gr.Tab("1. Project Info", id=0, elem_id="tab-project")
110
- with tab_project:
111
- tab1 = project.create_tab()
112
-
113
- # Tab 2: Building/Rooms
114
- tab_rooms = gr.Tab("2. Building/Rooms", id=1, elem_id="tab-rooms")
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
- tab3 = images.create_tab()
122
 
123
- # Tab 4: Observations
124
- tab_observations = gr.Tab("4. Observations", id=3, elem_id="tab-observations")
125
  with tab_observations:
126
- tab4 = observations.create_tab()
127
 
128
- # Tab 5: Generate Results
129
- tab_results = gr.Tab("5. Generate Results", id=4, elem_id="tab-results")
130
  with tab_results:
131
- tab5 = results.create_tab()
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
- *project.load_form_from_session(current_session), # 12 form values
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
- *project.load_form_from_session(current_session),
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 = project.load_form_from_session(new_session)
165
 
166
  return (
167
  new_session, # updated session_state
168
- *form_values, # 12 form values for Tab 1
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["project_name"],
180
- tab1["address"],
181
- tab1["city"],
182
- tab1["state"],
183
- tab1["zip_code"],
184
- tab1["client_name"],
185
- tab1["fire_date"],
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: Project Info
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  tab1["validate_btn"].click(
199
- fn=project.validate_and_continue,
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
- # ZIP code validation on blur
223
- tab1["zip_code"].blur(
224
- fn=project.validate_zip_format,
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["room_name"],
235
- tab2["room_floor"],
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["rooms_table"],
244
  tab2["validation_status"],
245
- tab2["room_count"],
246
- tab2["total_area"],
247
- tab2["total_volume"],
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
- # Show/hide custom height input based on preset selection
258
- tab2["room_height_preset"].change(
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["room_name"],
268
- tab2["room_floor"],
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=rooms.remove_last_room,
278
  inputs=[session_state],
279
  outputs=[
280
  session_state,
281
- tab2["rooms_table"],
282
  tab2["validation_status"],
283
- tab2["room_count"],
284
- tab2["total_area"],
285
- tab2["total_volume"],
286
  ],
287
  )
288
 
289
  tab2["clear_all_btn"].click(
290
- fn=rooms.clear_all_rooms,
291
  inputs=[session_state],
292
  outputs=[
293
  session_state,
294
- tab2["rooms_table"],
295
  tab2["validation_status"],
296
- tab2["room_count"],
297
- tab2["total_area"],
298
- tab2["total_volume"],
299
  ],
300
  )
301
 
302
  tab2["validate_btn"].click(
303
- fn=rooms.validate_and_continue,
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: Images
318
- tab3["add_image_btn"].click(
319
- fn=images.add_image,
320
  inputs=[
321
  session_state,
322
- tab3["image_upload"],
323
- tab3["room_select"],
324
- tab3["image_description"],
325
- ],
326
- outputs=[
327
- session_state,
328
- tab3["images_gallery"],
329
- tab3["validation_status"],
330
- tab3["image_count"],
331
- tab3["image_upload"],
332
- tab3["image_description"],
333
- tab3["room_select"],
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: Observations
383
- tab4["validate_btn"].click(
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
- tab5["processing_status"],
422
- tab5["progress_html"],
423
- tab5["annotated_gallery"],
424
- tab5["stats_output"],
425
- tab5["sow_output"],
426
- tab5["download_md"],
427
- tab5["download_pdf"],
428
  ],
429
  )
430
 
431
- tab5["regenerate_btn"].click(
432
  fn=results.generate_assessment,
433
  inputs=[session_state],
434
  outputs=[
435
  session_state,
436
- tab5["processing_status"],
437
- tab5["progress_html"],
438
- tab5["annotated_gallery"],
439
- tab5["stats_output"],
440
- tab5["sow_output"],
441
- tab5["download_md"],
442
- tab5["download_pdf"],
443
  ],
444
  )
445
 
446
- tab5["back_btn"].click(
447
- fn=lambda: gr.update(selected=3),
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 (Project): Load project form fields when selected
456
- tab_project.select(
457
- fn=project.load_form_from_session,
458
  inputs=[session_state],
459
  outputs=[
460
- tab1["project_name"],
461
- tab1["address"],
462
- tab1["city"],
463
- tab1["state"],
464
- tab1["zip_code"],
465
- tab1["client_name"],
466
- tab1["fire_date"],
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 3 (Images): Load gallery, count, and room dropdown when selected
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 room_choices, gallery, count, warning
493
 
494
  tab_images.select(
495
  fn=load_images_tab,
496
  inputs=[session_state],
497
  outputs=[
498
- tab3["room_select"],
499
- tab3["images_gallery"],
500
- tab3["image_count"],
501
- tab3["resume_warning"],
502
  ],
503
  )
504
 
505
- # Tab 4 (Observations): Load observation form fields when selected
506
  tab_observations.select(
507
  fn=observations.load_form_from_session,
508
  inputs=[session_state],
509
  outputs=[
510
- tab4["smoke_odor"],
511
- tab4["odor_intensity"],
512
- tab4["visible_soot"],
513
- tab4["soot_description"],
514
- tab4["large_char"],
515
- tab4["char_density"],
516
- tab4["ash_residue"],
517
- tab4["ash_description"],
518
- tab4["surface_discoloration"],
519
- tab4["discoloration_description"],
520
- tab4["dust_interference"],
521
- tab4["dust_notes"],
522
- tab4["wildfire_indicators"],
523
- tab4["wildfire_notes"],
524
- tab4["additional_notes"],
525
  ],
526
  )
527
 
528
- # Tab 5 (Results): Check preflight status when selected
529
  tab_results.select(
530
  fn=results.check_preflight,
531
  inputs=[session_state],
532
- outputs=[tab5["preflight_status"]],
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 rooms and project info
281
 
282
  Returns:
283
  Dictionary with all calculation results
284
  """
285
- logger.debug(f"Running calculations for {len(session.rooms)} rooms")
 
286
 
287
- # Calculate totals from rooms
288
- total_area = sum(r.length_ft * r.width_ft for r in session.rooms)
289
- total_volume = sum(
290
- r.length_ft * r.width_ft * r.ceiling_height_ft
291
- for r in session.rooms
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=session.project.construction_era or "post-2000",
317
- facility_classification=session.project.facility_classification or "non-operational",
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=session.project.facility_classification or "non-operational",
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.project.project_name}",
145
  generated_at=datetime.now().isoformat(),
146
  word_count=word_count,
147
  sections=[
148
- "Header", "Project Info", "Scope Summary", "Room Inventory",
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
- **Project:** {session.project.project_name}
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 project information section."""
166
- p = session.project
167
- return f"""## Project Information
168
 
169
  | Field | Value |
170
  |-------|-------|
171
- | **Project Name** | {p.project_name} |
172
- | **Address** | {p.address}, {p.city}, {p.state} {p.zip_code} |
173
- | **Client** | {p.client_name} |
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
- | **Total Rooms/Areas** | {len(session.rooms)} |
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 inventory table."""
199
- lines = ["## Room Inventory", ""]
200
- lines.append("| Room/Area | Dimensions | Area (SF) | Volume (CF) |")
201
- lines.append("|-----------|------------|-----------|-------------|")
202
-
203
- for room in session.rooms:
204
- area = room.length_ft * room.width_ft
205
- volume = area * room.ceiling_height_ft
206
- lines.append(
207
- f"| {room.name} | {room.length_ft:.0f}' × {room.width_ft:.0f}' × "
208
- f"{room.ceiling_height_ft:.0f}' | {area:,.0f} | {volume:,.0f} |"
209
- )
210
-
211
- return "\n".join(lines)
 
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"Project: {session.project.project_name}")
149
- logger.info(f"Facility: {session.project.facility_classification}")
150
- logger.info(f"Rooms: {len(session.rooms)}, Images: {len(session.images)}")
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": room_info.name if room_info else "Unknown",
246
- "id": img_meta.room_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
- "project_name": result.session.project.project_name,
393
- "facility_classification": result.session.project.facility_classification,
394
- "construction_era": result.session.project.construction_era,
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
- total_area = sum(r.length_ft * r.width_ft for r in session.rooms)
148
- total_volume = sum(
149
- r.length_ft * r.width_ft * r.ceiling_height_ft
150
- for r in session.rooms
151
- )
152
 
153
  return {
154
- "rooms": len(session.rooms),
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": session.project.facility_classification or "Not set",
159
- "construction_era": session.project.construction_era or "Not set",
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 project data,
4
- room information, images, and qualitative observations.
 
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
- project=ProjectFormData(**scenario.project_data),
333
- rooms=[room],
334
  images=images,
335
  observations=ObservationsFormData(**scenario.observations_data),
336
- name=scenario.project_data["project_name"],
337
  )
338
 
339
  # Mark tabs as complete since we have all data
340
  session.tab1_complete = True
341
- session.tab2_complete = True
342
- session.tab3_complete = len(images) > 0
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 a single room."""
 
 
 
 
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 4: Observations."""
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 by tab
103
- project: ProjectFormData = Field(default_factory=ProjectFormData)
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.project.project_name:
121
- return self.project.project_name
122
  return f"Assessment {self.session_id[:8]}"
123
 
124
  def validate_tab1(self) -> tuple[bool, list[str]]:
125
- """Validate Tab 1 (Project Info) is complete."""
126
  errors = []
127
- p = self.project
128
- if not p.project_name:
129
- errors.append("Project name is required")
130
- if not p.address:
131
- errors.append("Address is required")
132
- if not p.city:
133
- errors.append("City is required")
134
- if not p.state:
135
- errors.append("State is required")
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 (Building/Rooms) is complete."""
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 a room")
172
  return len(errors) == 0, errors
173
 
174
- def validate_tab4(self) -> tuple[bool, list[str]]:
175
- """Validate Tab 4 (Observations) is complete."""
176
- # Tab 4 has no required fields - all checkboxes default to False
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 for old sessions where assessor_credentials was a string.
259
  """
260
  try:
261
- # Parse JSON first to check for migrations needed
262
  data = json.loads(json_str)
263
 
264
- # Migration: Convert old string credentials to list
265
- if "project" in data and isinstance(data["project"].get("assessor_credentials"), str):
266
- old_creds = data["project"]["assessor_credentials"]
267
- # Convert comma-separated string to list, or empty list if empty
268
- if old_creds:
269
- data["project"]["assessor_credentials"] = [c.strip() for c in old_creds.split(",") if c.strip()]
270
  else:
271
- data["project"]["assessor_credentials"] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 project
4
- from . import rooms
5
  from . import images
6
  from . import observations
7
  from . import results
8
 
9
  __all__ = [
10
- "project",
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 3: Images.
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, Optional
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 3 UI components.
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="tab3_validation",
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 Rooms")
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, 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, room_id).
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, room_id
162
 
163
- # Get room name for filenames
164
- room_name = "unknown"
165
- for room in session.rooms:
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 room: {room_name}</span>
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, "", room_id
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 3 and proceed to Tab 4.
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=2) # Stay on Images tab
289
 
290
- is_valid, errors = session.validate_tab3()
291
 
292
  if is_valid:
293
- session.tab3_complete = True
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=3) # Go to tab index 3 (Observations)
301
  else:
302
- session.tab3_complete = False
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=2) # Stay on current tab
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>Project:</strong> {session.project.project_name}<br>
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_{session.project.project_name.replace(' ', '_')}_",
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 would use LLM generation.
 
262
  """
263
- # Build room summaries
264
- room_lines = []
265
- for room in session.rooms:
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
- ## Project Information
306
 
307
  | Field | Value |
308
  |-------|-------|
309
- | **Project Name** | {session.project.project_name} |
310
- | **Address** | {session.project.address}, {session.project.city}, {session.project.state} {session.project.zip_code} |
311
- | **Client** | {session.project.client_name} |
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
- | Total Rooms/Areas | {stats['total_rooms']} |
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 Inventory
332
 
333
- | Room/Area | Dimensions | Area (SF) | Volume (CF) |
334
- |-----------|------------|-----------|-------------|
335
- {room_table}
 
 
 
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]