Refactor: Oddělení business logiky + inline editor
Some checks failed
Build & Push Docker / build (push) Has been cancelled

- Nový modul scenar/core.py (491 řádků čisté logiky)
- Refactored cgi-bin/scenar.py (450 řádků CGI wrapper)
- Inline editor s JavaScript row managementem
- Custom exceptions (ScenarsError, ValidationError, TemplateError)
- Kompletní test coverage (10 testů, všechny )
- Fixed Dockerfile (COPY scenar/, requirements.txt)
- Fixed requirements.txt (openpyxl==3.1.5)
- Fixed pytest.ini (pythonpath = .)
- Nové testy: test_http_inline.py, test_inline_builder.py
- HTTP testy označeny jako @pytest.mark.integration
- Build script: scripts/build_image.sh
- Dokumentace: COMPLETION.md
This commit is contained in:
Martin Sukany
2025-11-13 16:06:32 +01:00
parent 9a7ffdeb2c
commit b7b56fe15f
16 changed files with 2674 additions and 541 deletions

View File

@@ -2,14 +2,13 @@ import os
import shutil
import subprocess
import time
import socket
import urllib.request
import pytest
def docker_available():
return shutil.which("docker") is not None
def podman_available():
return shutil.which("podman") is not None
def wait_for_http(url, timeout=30):
@@ -26,23 +25,29 @@ def wait_for_http(url, timeout=30):
raise RuntimeError(f"HTTP check failed after {timeout}s: {last_exc}")
@pytest.mark.skipif(not docker_available(), reason="Docker is not available on this runner")
@pytest.mark.skipif(not podman_available(), reason="Podman is not available on this runner")
@pytest.mark.integration
def test_build_run_and_cleanup_docker():
def test_build_run_and_cleanup_podman():
image_tag = "scenar-creator:latest"
container_name = "scenar-creator-test"
port = int(os.environ.get('SCENAR_TEST_PORT', '8080'))
# Ensure podman machine is running (macOS/Windows)
if not subprocess.run(["podman", "machine", "info"], capture_output=True).returncode == 0:
print("Starting podman machine...")
subprocess.run(["podman", "machine", "start"], check=True)
time.sleep(2)
# Build image
subprocess.run(["docker", "build", "-t", image_tag, "."], check=True)
subprocess.run(["podman", "build", "-t", image_tag, "."], check=True)
# Ensure no leftover container
subprocess.run(["docker", "rm", "-f", container_name], check=False)
subprocess.run(["podman", "rm", "-f", container_name], check=False)
try:
# Run container
subprocess.run([
"docker", "run", "-d", "--name", container_name, "-p", f"{port}:8080", image_tag
"podman", "run", "-d", "--name", container_name, "-p", f"{port}:8080", image_tag
], check=True)
# Wait for HTTP and verify content
@@ -52,5 +57,5 @@ def test_build_run_and_cleanup_docker():
finally:
# Cleanup container and image
subprocess.run(["docker", "rm", "-f", container_name], check=False)
subprocess.run(["docker", "rmi", image_tag], check=False)
subprocess.run(["podman", "rm", "-f", container_name], check=False)
subprocess.run(["podman", "rmi", image_tag], check=False)

138
tests/test_http_inline.py Normal file
View File

@@ -0,0 +1,138 @@
"""
HTTP/CGI integration tests for inline builder workflow.
Tests the real form submission through the CGI endpoint.
"""
import pytest
import urllib.parse
import subprocess
import json
from io import BytesIO
def send_cgi_request(post_data=None, get_params=None):
"""
Send HTTP request to local CGI server and return response.
Assumes server is running on localhost:8000
"""
import urllib.request
import urllib.error
url = "http://localhost:8000/cgi-bin/scenar.py"
if get_params:
url += "?" + urllib.parse.urlencode(get_params)
if post_data:
data = urllib.parse.urlencode(post_data).encode('utf-8')
else:
data = None
try:
req = urllib.request.Request(url, data=data, method='POST' if data else 'GET')
with urllib.request.urlopen(req, timeout=10) as response:
return response.read().decode('utf-8'), response.status
except Exception as e:
return str(e), None
@pytest.mark.integration
def test_inline_builder_form_submission_valid():
"""Test inline builder with valid form data."""
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'step': 'builder',
# Schedule row 0
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Opening',
'typ_0': 'KEYNOTE',
'garant_0': 'John Smith',
'poznamka_0': 'Welcome',
# Type definition 0
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Main keynote',
'type_color_0': '#FF0000',
}
response, status = send_cgi_request(post_data=form_data)
# Should get 200 OK
assert status == 200, f"Expected 200, got {status}"
# Response should contain success message
assert '✅ Scénář úspěšně vygenerován' in response or 'Stáhnout scénář' in response, \
f"Expected success message in response, got: {response[:500]}"
# Should have download link
assert '/tmp/' in response, "Expected download link in response"
@pytest.mark.integration
def test_inline_builder_missing_type():
"""Test that missing type definition is rejected."""
form_data = {
'title': 'Test Event',
'detail': 'Test Detail',
'step': 'builder',
# Schedule with type WORKSHOP
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Workshop',
'typ_0': 'WORKSHOP', # This type is not defined!
'garant_0': 'John',
'poznamka_0': '',
# Only KEYNOTE defined
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote',
'type_color_0': '#FF0000',
}
response, status = send_cgi_request(post_data=form_data)
# Should get error response
assert status == 200 # CGI returns 200 with error content
assert 'Chyba' in response or 'Missing type' in response, \
f"Expected error message, got: {response[:500]}"
@pytest.mark.integration
def test_home_page_load():
"""Test that home page loads without errors."""
response, status = send_cgi_request()
assert status == 200
assert '<!DOCTYPE html>' in response
assert 'Scenar Creator' in response
assert 'Importovat Excel' in response # Tab 1
assert 'Vytvořit inline' in response # Tab 2
@pytest.mark.integration
def test_inline_editor_tabs_present():
"""Test that inline editor form elements are present."""
response, status = send_cgi_request()
assert status == 200
# Check for key form elements
assert 'id="builderForm"' in response
assert 'id="scheduleTable"' in response
assert 'id="typesContainer"' in response
assert 'id="availableTypes"' in response # datalist
# Check for JavaScript functions
assert 'function addScheduleRow' in response
assert 'function removeScheduleRow' in response
assert 'function addTypeRow' in response
assert 'function removeTypeRow' in response
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@@ -0,0 +1,261 @@
"""
End-to-end tests for inline builder (without Excel upload).
Tests the full form submission flow from HTML form to timetable generation.
"""
import pytest
from datetime import date, time
import pandas as pd
from scenar.core import parse_inline_schedule, parse_inline_types, create_timetable, ValidationError
def test_inline_builder_valid_form():
"""Test inline builder with valid schedule and types."""
# Simulate form data from HTML
form_data = {
# Metadata
'title': 'Test Conference',
'detail': 'Testing inline builder',
'step': 'builder',
# Schedule rows (2 rows)
'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 definitions
'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',
}
# Parse schedule
schedule = parse_inline_schedule(form_data)
assert len(schedule) == 2
assert schedule.iloc[0]['Program'] == 'Opening Keynote'
assert schedule.iloc[0]['Typ'] == 'KEYNOTE'
# Parse types
descriptions, colors = parse_inline_types(form_data)
assert len(descriptions) == 2
assert descriptions['KEYNOTE'] == 'Main Address'
assert colors['KEYNOTE'] == 'FFFF0000'
# Generate timetable
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'
assert ws['A2'].value == 'Testing inline builder'
def test_inline_builder_missing_type_definition():
"""Test that undefined type in schedule raises error."""
form_data = {
'title': 'Bad Conference',
'detail': 'Missing type definition',
# Schedule with UNKNOWN type
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
'konec_0': '10:00',
'program_0': 'Unknown Program',
'typ_0': 'UNKNOWN_TYPE', # This type is not defined below!
'garant_0': 'Someone',
'poznamka_0': '',
# Only define LECTURE, not UNKNOWN_TYPE
'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)
# Should fail because UNKNOWN_TYPE is not in colors dict
with pytest.raises(Exception): # ScenarsError
create_timetable(schedule, 'Bad', 'Bad', descriptions, colors)
def test_inline_builder_empty_type_definition():
"""Test that empty type definitions are skipped."""
form_data = {
'title': 'Conference',
'detail': 'With empty types',
'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': '',
# One empty type definition (should be skipped)
'type_name_0': '',
'type_desc_0': 'Empty type',
'type_color_0': '#FF0000',
# One valid type
'type_name_1': 'WORKSHOP',
'type_desc_1': 'Workshop Type',
'type_color_1': '#0070C0',
}
descriptions, colors = parse_inline_types(form_data)
# Empty type should be skipped
assert 'WORKSHOP' in descriptions
assert '' not in descriptions
def test_inline_builder_overlapping_times():
"""Test schedule with overlapping time slots."""
form_data = {
'title': 'Conference',
'detail': 'Overlapping schedule',
# Two programs at same time
'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', # Overlaps with Program A
'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)
# Should allow overlapping times — timetable will show them
assert len(schedule) == 2
# Generate timetable (may have rendering issues but should complete)
wb = create_timetable(schedule, 'Conf', 'Test', descriptions, colors)
assert wb is not None
def test_inline_builder_multiday():
"""Test schedule spanning multiple days."""
form_data = {
'title': 'Multi-day Conference',
'detail': 'Two-day event',
# Day 1
'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': '',
# Day 2
'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
# Should have multiple date rows
assert ws['A1'].value == 'Multi-day'
def test_inline_builder_validation_errors():
"""Test validation of inline form data."""
# Missing required field
form_data_missing = {
'datum_0': '2025-11-13',
# Missing zacatek_0, konec_0, program_0, typ_0
'garant_0': 'John',
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data_missing)
def test_inline_builder_with_empty_rows():
"""Test that empty schedule rows are skipped."""
form_data = {
'title': 'Test',
'detail': 'With empty rows',
# Valid row
'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': '',
# Empty row (all fields missing)
'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)
# Should only have 1 row (empty row skipped)
assert len(schedule) == 1
assert schedule.iloc[0]['Program'] == 'Program 1'

View File

@@ -1,39 +1,11 @@
import io
import os
import pandas as pd
import importlib.util
import sys
import types
def load_scenar_module():
repo_root = os.path.dirname(os.path.dirname(__file__))
scenar_path = os.path.join(repo_root, 'cgi-bin', 'scenar.py')
# Provide a minimal fake `cgi` module so top-level imports in the CGI script don't fail
if 'cgi' not in sys.modules:
fake_cgi = types.ModuleType('cgi')
class FakeFieldStorage:
def getvalue(self, key, default=None):
return default
def keys(self):
return []
def __contains__(self, item):
return False
fake_cgi.FieldStorage = FakeFieldStorage
sys.modules['cgi'] = fake_cgi
# minimal fake cgitb (some environments don't expose it)
if 'cgitb' not in sys.modules:
fake_cgitb = types.ModuleType('cgitb')
def fake_enable():
return None
fake_cgitb.enable = fake_enable
sys.modules['cgitb'] = fake_cgitb
spec = importlib.util.spec_from_file_location('scenar', scenar_path)
module = importlib.util.module_from_spec(spec)
# executing the module will run top-level CGI code (prints etc.) but defines functions we need
spec.loader.exec_module(module)
return module
import pytest
from datetime import date, time
from scenar.core import (
read_excel, create_timetable, get_program_types, ScenarsError,
parse_inline_schedule, parse_inline_types, ValidationError
)
def make_excel_bytes(df: pd.DataFrame) -> bytes:
@@ -44,8 +16,6 @@ def make_excel_bytes(df: pd.DataFrame) -> bytes:
def test_read_excel_happy_path():
scenar = load_scenar_module()
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['09:00'],
@@ -57,7 +27,7 @@ def test_read_excel_happy_path():
})
content = make_excel_bytes(df)
valid, errors = scenar.read_excel(content)
valid, errors = read_excel(content)
assert isinstance(valid, pd.DataFrame)
assert len(errors) == 0
@@ -66,8 +36,6 @@ def test_read_excel_happy_path():
def test_read_excel_invalid_time():
scenar = load_scenar_module()
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': ['not-a-time'],
@@ -79,8 +47,205 @@ def test_read_excel_invalid_time():
})
content = make_excel_bytes(df)
valid, errors = scenar.read_excel(content)
valid, errors = read_excel(content)
# invalid time should produce at least one error row and valid may be empty
assert isinstance(errors, list)
assert len(errors) >= 1
def test_get_program_types():
"""Test form field parsing for program type/color/description."""
form_data = {
'type_code_0': 'WORKSHOP',
'type_code_1': 'LECTURE',
'desc_0': 'Workshop description',
'desc_1': 'Lecture description',
'color_0': '#FF0000',
'color_1': '#00FF00',
}
# get_program_types returns (descriptions_dict, colors_dict) tuple
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' # FF prefix added
assert colors['LECTURE'] == 'FF00FF00'
def test_create_timetable():
"""Test Excel timetable generation with properly parsed times."""
from datetime import time
# Create test data with time objects (as returned by read_excel)
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)], # Use time object, not string
'Konec': [time(10, 0)],
'Program': ['Test Program'],
'Typ': ['WORKSHOP'],
'Garant': ['John Doe'],
'Poznamka': ['Test note'],
})
program_descriptions = {
'WORKSHOP': 'Workshop Type',
}
program_colors = {
'WORKSHOP': 'FF0070C0', # AARRGGBB format
}
# create_timetable returns openpyxl.Workbook
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():
"""Test timetable generation with separate color dict."""
from datetime import time
df = pd.DataFrame({
'Datum': [pd.Timestamp('2025-11-13').date()],
'Zacatek': [time(9, 0)], # Use time object
'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"
def test_parse_inline_schedule():
"""Test parsing inline schedule form data."""
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():
"""Test that missing required fields raise ValidationError."""
form_data = {
'datum_0': '2025-11-13',
'zacatek_0': '09:00',
# Missing konec_0, program_0, typ_0
}
with pytest.raises(ValidationError):
parse_inline_schedule(form_data)
def test_parse_inline_types():
"""Test parsing inline type definitions."""
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'
def test_inline_workflow_integration():
"""Test end-to-end inline workflow: parse form → create timetable."""
# Schedule form data
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',
}
# Type form data
types_form = {
'type_name_0': 'KEYNOTE',
'type_desc_0': 'Keynote Speech',
'type_color_0': '#FF0000',
}
# Merge forms
form_data = {**schedule_form, **types_form}
# Parse
df = parse_inline_schedule(form_data)
descriptions, colors = parse_inline_types(form_data)
# Generate timetable
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"