feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
Some checks failed
Build & Push Docker / build (push) Has been cancelled

- Remove all Excel code (import, export, template, pandas, openpyxl)
- New canvas-based schedule editor with drag & drop (interact.js)
- Modern 3-panel UI: sidebar, canvas, documentation tab
- New data model: Block with id/date/start/end, ProgramType with id/name/color
- Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf
- Rewritten PDF generator using ScenarioDocument directly (no DataFrame)
- Professional PDF output: dark header, colored blocks, merged cells, legend, footer
- Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types
- 30 tests passing (API, core models, PDF generation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 17:02:51 +01:00
parent e2bdadd0ce
commit 25fd578543
27 changed files with 2004 additions and 3016 deletions

View File

@@ -1,532 +1,97 @@
"""
Core business logic tests — adapted from original test_read_excel.py and test_inline_builder.py.
Tests the refactored app.core modules.
Core logic tests for Scenar Creator v3.
Tests models, validation, and document structure.
"""
import io
import pandas as pd
import pytest
from datetime import date, time
from pydantic import ValidationError as PydanticValidationError
from app.core import (
read_excel, create_timetable, get_program_types, ScenarsError,
parse_inline_schedule, parse_inline_types, ValidationError,
validate_inputs, normalize_time,
)
from app.models.event import Block, ProgramType, EventInfo, ScenarioDocument
from app.core.validator import ScenarsError, ValidationError
def make_excel_bytes(df: pd.DataFrame) -> bytes:
bio = io.BytesIO()
with pd.ExcelWriter(bio, engine='openpyxl') as writer:
df.to_excel(writer, index=False)
return bio.getvalue()
# --- Model tests ---
def test_block_default_id():
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
assert b.id is not None
assert len(b.id) > 0
def test_block_optional_fields():
b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws")
assert b.responsible is None
assert b.notes is None
def test_block_with_all_fields():
b = Block(
id="custom-id", date="2026-03-01", start="09:00", end="10:00",
title="Full Block", type_id="ws", responsible="John", notes="A note"
)
assert b.id == "custom-id"
assert b.responsible == "John"
assert b.notes == "A note"
def test_program_type():
pt = ProgramType(id="main", name="Main Program", color="#3B82F6")
assert pt.id == "main"
assert pt.name == "Main Program"
assert pt.color == "#3B82F6"
def test_event_info_minimal():
e = EventInfo(title="Test")
assert e.title == "Test"
assert e.subtitle is None
assert e.date is None
assert e.location is None
def test_event_info_full():
e = EventInfo(title="Event", subtitle="Sub", date="2026-03-01", location="Prague")
assert e.location == "Prague"
def test_scenario_document():
doc = ScenarioDocument(
event=EventInfo(title="Test"),
program_types=[ProgramType(id="ws", name="Workshop", color="#FF0000")],
blocks=[Block(date="2026-03-01", start="09:00", end="10:00", title="B1", type_id="ws")]
)
assert doc.version == "1.0"
assert len(doc.blocks) == 1
assert len(doc.program_types) == 1
def test_scenario_document_serialization():
doc = ScenarioDocument(
event=EventInfo(title="Test"),
program_types=[ProgramType(id="ws", name="Workshop", color="#FF0000")],
blocks=[Block(id="b1", date="2026-03-01", start="09:00", end="10:00", title="B1", type_id="ws")]
)
data = doc.model_dump(mode="json")
assert data["event"]["title"] == "Test"
assert data["blocks"][0]["type_id"] == "ws"
assert data["blocks"][0]["id"] == "b1"
def test_scenario_document_missing_title():
with pytest.raises(PydanticValidationError):
ScenarioDocument(
event=EventInfo(),
program_types=[],
blocks=[]
)
# --- Validator tests ---
def test_validate_inputs_valid():
validate_inputs("Title", "Detail", 100)
def test_scenars_error_hierarchy():
assert issubclass(ValidationError, ScenarsError)
def test_validate_inputs_empty_title():
with pytest.raises(ValidationError):
validate_inputs("", "Detail", 100)
def test_validate_inputs_long_title():
with pytest.raises(ValidationError):
validate_inputs("x" * 201, "Detail", 100)
def test_validate_inputs_file_too_large():
with pytest.raises(ValidationError):
validate_inputs("Title", "Detail", 11 * 1024 * 1024)
def test_normalize_time_hhmm():
t = normalize_time("09:00")
assert t == time(9, 0)
def test_normalize_time_hhmmss():
t = normalize_time("09:00:00")
assert t == time(9, 0)
def test_normalize_time_invalid():
assert normalize_time("invalid") is None
# --- Excel reader tests ---
def test_read_excel_happy_path():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Test program'],
'Typ': ['WORKSHOP'],
'Garant': ['Garant Name'],
'Poznamka': ['Pozn']
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(valid, pd.DataFrame)
assert len(errors) == 0
assert len(valid) == 1
assert valid.iloc[0]['Program'] == 'Test program'
def test_read_excel_invalid_time():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['not-a-time'],
'Konec': ['10:00'],
'Program': ['Bad Time'],
'Typ': ['LECTURE'],
'Garant': [None],
'Poznamka': [None]
})
content = make_excel_bytes(df)
valid, errors = read_excel(content)
assert isinstance(errors, list)
assert len(errors) >= 1
def test_get_program_types():
form_data = {
'type_code_0': 'WORKSHOP',
'type_code_1': 'LECTURE',
'desc_0': 'Workshop description',
'desc_1': 'Lecture description',
'color_0': '#FF0000',
'color_1': '#00FF00',
}
descriptions, colors = get_program_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop description'
assert descriptions['LECTURE'] == 'Lecture description'
assert colors['WORKSHOP'] == 'FFFF0000'
assert colors['LECTURE'] == 'FF00FF00'
# --- Timetable tests ---
def test_create_timetable():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
program_descriptions = {'WORKSHOP': 'Workshop Type'}
program_colors = {'WORKSHOP': 'FF0070C0'}
wb = create_timetable(
df, title="Test Timetable", detail="Test Detail",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
assert len(wb.sheetnames) > 0
ws = wb.active
assert ws['A1'].value == "Test Timetable"
assert ws['A2'].value == "Test Detail"
def test_create_timetable_with_color_dict():
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)],
'Konec': [time(10, 0)],
'Program': ['Lecture 101'],
'Typ': ['LECTURE'],
'Garant': ['Dr. Smith'],
'Poznamka': [None],
})
program_descriptions = {'LECTURE': 'Standard Lecture'}
program_colors = {'LECTURE': 'FFFF6600'}
wb = create_timetable(
df, title="Advanced Timetable", detail="With color dict",
program_descriptions=program_descriptions,
program_colors=program_colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Advanced Timetable"
# --- Inline schedule tests ---
def test_parse_inline_schedule():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Test Program',
'typ_0': 'WORKSHOP',
'garant_0': 'John Doe',
'poznamka_0': 'Test note',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Another Program',
'typ_1': 'LECTURE',
'garant_1': 'Jane Smith',
'poznamka_1': '',
}
df = parse_inline_schedule(form_data)
assert isinstance(df, pd.DataFrame)
assert len(df) == 2
assert df.iloc[0]['Program'] == 'Test Program'
assert df.iloc[0]['Typ'] == 'WORKSHOP'
assert df.iloc[1]['Program'] == 'Another Program'
assert df.iloc[1]['Typ'] == 'LECTURE'
def test_parse_inline_schedule_missing_required():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data)
def test_parse_inline_types():
form_data = {
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop Type',
'type_color_0': '#0070C0',
'type_name_1': 'LECTURE',
'type_desc_1': 'Lecture Type',
'type_color_1': '#FF6600',
}
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['WORKSHOP'] == 'Workshop Type'
assert descriptions['LECTURE'] == 'Lecture Type'
assert colors['WORKSHOP'] == 'FF0070C0'
assert colors['LECTURE'] == 'FFFF6600'
# --- Integration tests ---
def test_inline_workflow_integration():
schedule_form = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Start of event',
}
types_form = {
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF0000',
}
form_data = {**schedule_form, **types_form}
df = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(
df, title="Integration Test Event", detail="Testing inline workflow",
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == "Integration Test Event"
assert ws['A2'].value == "Testing inline workflow"
def test_excel_import_to_step2_workflow():
import base64
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
'Konec': ['10:00'],
'Program': ['Opening Keynote'],
'Typ': ['KEYNOTE'],
'Garant': ['John Smith'],
'Poznamka': ['Welcome speech']
})
file_content = make_excel_bytes(df)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 1
program_types = sorted([str(t).strip() for t in valid_data["Typ"].dropna().unique()])
assert program_types == ['KEYNOTE']
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'file_content_base64': file_content_base64,
'type_code_0': 'KEYNOTE',
'desc_0': 'Main keynote presentation',
'color_0': '#FF0000',
'step': '3'
}
descriptions, colors = get_program_types(form_data)
assert descriptions['KEYNOTE'] == 'Main keynote presentation'
assert colors['KEYNOTE'] == 'FFFF0000'
file_content_decoded = base64.b64decode(form_data['file_content_base64'])
data, _ = read_excel(file_content_decoded)
wb = create_timetable(
data, title=form_data['title'], detail=form_data['detail'],
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Event'
assert ws['A2'].value == 'Test Detail'
def test_excel_import_to_inline_editor_workflow():
import base64
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date(), pd.Timestamp('2025-11-14').date()],
'Zacatek': ['09:00', '14:00'],
'Konec': ['10:00', '15:30'],
'Program': ['Morning Session', 'Afternoon Workshop'],
'Typ': ['KEYNOTE', 'WORKSHOP'],
'Garant': ['Alice', 'Bob'],
'Poznamka': ['', 'Hands-on']
})
file_content = make_excel_bytes(df)
valid_data, errors = read_excel(file_content)
assert len(errors) == 0
assert len(valid_data) == 2
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
file_content_decoded = base64.b64decode(file_content_base64)
data_in_editor, _ = read_excel(file_content_decoded)
assert len(data_in_editor) == 2
assert data_in_editor.iloc[0]['Program'] == 'Morning Session'
assert data_in_editor.iloc[1]['Program'] == 'Afternoon Workshop'
program_types = sorted([str(t).strip() for t in data_in_editor["Typ"].dropna().unique()])
assert set(program_types) == {'KEYNOTE', 'WORKSHOP'}
# --- Inline builder tests (from test_inline_builder.py) ---
def test_inline_builder_valid_form():
form_data = {
'title': 'Test Conference',
'detail': 'Testing inline builder',
'step': 'builder',
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening Keynote',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. Smith',
'poznamka_0': 'Welcome speech',
'datum_1': '2025-11-13',
'zacatek_1': '10:30',
'konec_1': '11:30',
'program_1': 'Workshop: Python',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane Doe',
'poznamka_1': 'Hands-on coding',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Main Address',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Interactive Session',
'type_color_1': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 2
assert schedule.iloc[0]['Program'] == 'Opening Keynote'
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['KEYNOTE'] == 'Main Address'
assert colors['KEYNOTE'] == 'FFFF0000'
wb = create_timetable(
schedule, title=form_data['title'], detail=form_data['detail'],
program_descriptions=descriptions, program_colors=colors
)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Test Conference'
def test_inline_builder_missing_type_definition():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Unknown Program',
'typ_0': 'UNKNOWN_TYPE',
'garant_0': 'Someone',
'poznamka_0': '',
'type_name_0': 'LECTURE',
'type_desc_0': 'Standard Lecture',
'type_color_0': '#3498db',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
with pytest.raises(Exception):
create_timetable(schedule, 'Bad', 'Bad', descriptions, colors)
def test_inline_builder_empty_type_definition():
form_data = {
'type_name_0': '',
'type_desc_0': 'Empty type',
'type_color_0': '#FF0000',
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Workshop Type',
'type_color_1': '#0070C0',
}
descriptions, colors = parse_inline_types(form_data)
assert 'WORKSHOP' in descriptions
assert '' not in descriptions
def test_inline_builder_overlapping_times():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program A',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '2025-11-13',
'zacatek_1': '09:30',
'konec_1': '10:30',
'program_1': 'Program B',
'typ_1': 'WORKSHOP',
'garant_1': 'Jane',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
assert len(schedule) == 2
wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors)
assert wb is not None
def test_inline_builder_multiday():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Day 1 Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'Dr. A',
'poznamka_0': '',
'datum_1': '2025-11-14',
'zacatek_1': '09:00',
'konec_1': '10:00',
'program_1': 'Day 2 Opening',
'typ_1': 'KEYNOTE',
'garant_1': 'Dr. B',
'poznamka_1': '',
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF6600',
}
schedule = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
wb = create_timetable(schedule, 'Multi-day', 'Test', descriptions, colors)
assert wb is not None
ws = wb.active
assert ws['A1'].value == 'Multi-day'
def test_inline_builder_validation_errors():
form_data_missing = {
'datum_0': '2025-11-13',
'garant_0': 'John',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data_missing)
def test_inline_builder_with_empty_rows():
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Program 1',
'typ_0': 'WORKSHOP',
'garant_0': 'John',
'poznamka_0': '',
'datum_1': '',
'zacatek_1': '',
'konec_1': '',
'program_1': '',
'typ_1': '',
'garant_1': '',
'poznamka_1': '',
'type_name_0': 'WORKSHOP',
'type_desc_0': 'Workshop',
'type_color_0': '#0070C0',
}
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 1
assert schedule.iloc[0]['Program'] == 'Program 1'
def test_validation_error_message():
err = ValidationError("test error")
assert str(err) == "test error"