diff --git a/CMakeLists.txt b/CMakeLists.txt index 30fadf0..a60a838 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -81,6 +81,7 @@ add_library(safecrowd_domain STATIC src/domain/Geometry2D.h src/domain/PopulationSpec.h src/domain/ScenarioAuthoring.h + src/domain/ScenarioAuthoring.cpp src/domain/ScenarioRiskMetrics.h src/domain/ScenarioRiskMetrics.cpp src/domain/ScenarioRiskMetricsSystem.cpp @@ -154,6 +155,7 @@ if (BUILD_TESTING) tests/ResourceStoreTests.cpp tests/DeterministicRngTests.cpp tests/ScenarioSimulationRunnerTests.cpp + tests/ScenarioAuthoringTests.cpp ) target_include_directories(safecrowd_tests diff --git a/src/application/MainWindow.cpp b/src/application/MainWindow.cpp index 72ba6cd..e3172aa 100644 --- a/src/application/MainWindow.cpp +++ b/src/application/MainWindow.cpp @@ -14,12 +14,14 @@ #include "application/ScenarioAuthoringWidget.h" #include "application/ScenarioResultWidget.h" #include "application/ScenarioRunWidget.h" +#include "domain/DemoFixtureService.h" #include "domain/DemoLayouts.h" #include "domain/DxfImportService.h" #include "domain/ImportIssue.h" #include "domain/ImportOrchestrator.h" #include "domain/ImportValidationService.h" #include "domain/SafeCrowdDomain.h" +#include "domain/ScenarioAuthoring.h" namespace safecrowd::application { namespace { @@ -33,8 +35,11 @@ void applySavedReviewState(const ProjectMetadata& metadata, safecrowd::domain::I } safecrowd::domain::ImportResult makeDemoImportResult() { + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createSprint1DemoFixture(); + safecrowd::domain::ImportResult result; - result.layout = safecrowd::domain::DemoLayouts::demoFacility(); + result.layout = fixture.layout; safecrowd::domain::ImportValidationService validator; result.issues = validator.validate(*result.layout); @@ -44,6 +49,93 @@ safecrowd::domain::ImportResult makeDemoImportResult() { return result; } +ProjectWorkspaceState makeEvacuationScenarioDemoWorkspace() { + using namespace safecrowd::domain; + + safecrowd::domain::DemoFixtureService fixtureService; + const auto fixture = fixtureService.createSprint1DemoFixture(); + + auto alternative = duplicateScenarioDraft(fixture.baselineScenario, "scenario-2", "Doorway blocked alternative"); + alternative.control.connectionBlocks.push_back({ + .id = "block-1", + .connectionId = DemoLayouts::Sprint1FacilityIds::DoorwayConnectionId, + .intervals = {{0.0, 60.0}}, + }); + alternative.variationDiffKeys = computeScenarioDiffKeys(fixture.baselineScenario, alternative); + + SavedScenarioAuthoringState authoring; + authoring.scenarios.push_back({ + .draft = fixture.baselineScenario, + .baseScenarioId = {}, + .stagedForRun = true, + }); + authoring.scenarios.push_back({ + .draft = alternative, + .baseScenarioId = fixture.baselineScenario.scenarioId, + .stagedForRun = true, + }); + authoring.currentScenarioIndex = 1; + authoring.navigationView = SavedNavigationView::Events; + authoring.rightPanelMode = SavedRightPanelMode::Scenario; + + SimulationFrame resultFrame; + resultFrame.elapsedSeconds = 96.0; + resultFrame.complete = true; + resultFrame.totalAgentCount = 100; + resultFrame.evacuatedAgentCount = 100; + + ScenarioResultArtifacts artifacts; + artifacts.evacuationProgress = { + {.timeSeconds = 0.0, .evacuatedCount = 0, .totalCount = 100, .evacuatedRatio = 0.0}, + {.timeSeconds = 30.0, .evacuatedCount = 24, .totalCount = 100, .evacuatedRatio = 0.24}, + {.timeSeconds = 60.0, .evacuatedCount = 72, .totalCount = 100, .evacuatedRatio = 0.72}, + {.timeSeconds = 96.0, .evacuatedCount = 100, .totalCount = 100, .evacuatedRatio = 1.0}, + }; + artifacts.replayFrames = {resultFrame}; + artifacts.timingSummary.t50Seconds = 48.0; + artifacts.timingSummary.t90Seconds = 82.0; + artifacts.timingSummary.t95Seconds = 90.0; + artifacts.timingSummary.finalEvacuationTimeSeconds = 96.0; + artifacts.timingSummary.targetTimeSeconds = alternative.execution.timeLimitSeconds; + artifacts.timingSummary.marginSeconds = alternative.execution.timeLimitSeconds - 96.0; + artifacts.exitUsage.push_back({ + .exitZoneId = DemoLayouts::Sprint1FacilityIds::ExitZoneId, + .exitLabel = "Primary exit", + .floorId = DemoLayouts::Sprint1FacilityIds::FloorId, + .evacuatedCount = 100, + .usageRatio = 1.0, + .lastExitTimeSeconds = 96.0, + }); + artifacts.zoneCompletion.push_back({ + .zoneId = DemoLayouts::Sprint1FacilityIds::MainRoomZoneId, + .zoneLabel = "Main room", + .floorId = DemoLayouts::Sprint1FacilityIds::FloorId, + .initialCount = 100, + .evacuatedCount = 100, + .lastCompletionTimeSeconds = 96.0, + }); + artifacts.placementCompletion.push_back({ + .placementId = "placement-1", + .zoneId = DemoLayouts::Sprint1FacilityIds::MainRoomZoneId, + .floorId = DemoLayouts::Sprint1FacilityIds::FloorId, + .initialCount = 100, + .evacuatedCount = 100, + .lastCompletionTimeSeconds = 96.0, + }); + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioResult; + workspace.authoring = std::move(authoring); + workspace.runningScenario = alternative; + workspace.result = SavedScenarioResultState{ + .scenario = std::move(alternative), + .frame = resultFrame, + .risk = {}, + .artifacts = std::move(artifacts), + }; + return workspace; +} + safecrowd::domain::ImportResult makeBlankImportResult(const QString& projectName) { safecrowd::domain::ImportResult result; result.layout = safecrowd::domain::FacilityLayout2D{ @@ -314,7 +406,9 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { } ProjectWorkspaceState workspace; - if (!ProjectPersistence::loadProjectWorkspace(metadata, &workspace)) { + if (metadata.isBuiltInEvacuationScenarioDemo()) { + workspace = makeEvacuationScenarioDemoWorkspace(); + } else if (!ProjectPersistence::loadProjectWorkspace(metadata, &workspace)) { showLayoutReview(metadata, std::move(importResult)); return; } @@ -340,7 +434,10 @@ void MainWindow::openProject(const ProjectMetadata& metadata) { workspace.result->scenario, workspace.result->frame, workspace.result->risk, - workspace.result->artifacts); + workspace.result->artifacts, + workspace.authoring.has_value() + ? std::make_optional(initialStateFromSaved(*workspace.authoring, *importResult.layout)) + : std::nullopt); return; } break; @@ -533,7 +630,8 @@ void MainWindow::showScenarioResult( const safecrowd::domain::ScenarioDraft& scenario, const safecrowd::domain::SimulationFrame& frame, const safecrowd::domain::ScenarioRiskSnapshot& risk, - const safecrowd::domain::ScenarioResultArtifacts& artifacts) { + const safecrowd::domain::ScenarioResultArtifacts& artifacts, + std::optional returnAuthoringState) { setCentralWidget(new ScenarioResultWidget( currentProject_.name, layout, @@ -556,6 +654,7 @@ void MainWindow::showScenarioResult( showLayoutReview(currentProject_); } }, + std::move(returnAuthoringState), this)); } diff --git a/src/application/MainWindow.h b/src/application/MainWindow.h index 68b8dd5..ba02aea 100644 --- a/src/application/MainWindow.h +++ b/src/application/MainWindow.h @@ -45,7 +45,8 @@ class MainWindow : public QMainWindow { const safecrowd::domain::ScenarioDraft& scenario, const safecrowd::domain::SimulationFrame& frame, const safecrowd::domain::ScenarioRiskSnapshot& risk, - const safecrowd::domain::ScenarioResultArtifacts& artifacts); + const safecrowd::domain::ScenarioResultArtifacts& artifacts, + std::optional returnAuthoringState = std::nullopt); safecrowd::domain::SafeCrowdDomain& domain_; ProjectMetadata currentProject_{}; diff --git a/src/application/ProjectMetadata.h b/src/application/ProjectMetadata.h index 65c97e6..59becbc 100644 --- a/src/application/ProjectMetadata.h +++ b/src/application/ProjectMetadata.h @@ -8,6 +8,10 @@ inline QString builtInDemoLayoutPath() { return QStringLiteral("safecrowd://demo/sprint1-facility"); } +inline QString builtInEvacuationScenarioDemoLayoutPath() { + return QStringLiteral("safecrowd://demo/evacuation-scenario"); +} + struct ProjectMetadata { QString name{}; QString folderPath{}; @@ -15,7 +19,11 @@ struct ProjectMetadata { QString savedAt{}; bool isBuiltInDemo() const noexcept { - return layoutPath == builtInDemoLayoutPath(); + return layoutPath == builtInDemoLayoutPath() || isBuiltInEvacuationScenarioDemo(); + } + + bool isBuiltInEvacuationScenarioDemo() const noexcept { + return layoutPath == builtInEvacuationScenarioDemoLayoutPath(); } bool isBlankLayoutProject() const noexcept { @@ -37,4 +45,11 @@ inline ProjectMetadata makeBuiltInDemoProject() { }; } +inline ProjectMetadata makeBuiltInEvacuationScenarioDemoProject() { + return { + .name = QStringLiteral("Evacuation Scenario Demo"), + .layoutPath = builtInEvacuationScenarioDemoLayoutPath(), + }; +} + } // namespace safecrowd::application diff --git a/src/application/ProjectPersistence.cpp b/src/application/ProjectPersistence.cpp index d551e0c..fcba823 100644 --- a/src/application/ProjectPersistence.cpp +++ b/src/application/ProjectPersistence.cpp @@ -1211,6 +1211,7 @@ void updateLiveValidationIssues(safecrowd::domain::ImportResult* importResult) { QList ProjectPersistence::loadRecentProjects() { QList projects; projects.push_back(makeBuiltInDemoProject()); + projects.push_back(makeBuiltInEvacuationScenarioDemoProject()); const auto document = readJsonDocument(recentProjectsPath()); if (!document.isObject()) { diff --git a/src/application/ScenarioAuthoringWidget.cpp b/src/application/ScenarioAuthoringWidget.cpp index 120a804..c565fe0 100644 --- a/src/application/ScenarioAuthoringWidget.cpp +++ b/src/application/ScenarioAuthoringWidget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,226 @@ QString blockScheduleSummary(const safecrowd::domain::ConnectionBlockDraft& bloc return intervals.join(", "); } +int draftOccupantCount(const safecrowd::domain::ScenarioDraft& scenario) { + int total = 0; + for (const auto& placement : scenario.population.initialPlacements) { + total += static_cast(placement.targetAgentCount); + } + return total; +} + +QString signedDelta(int delta) { + return delta > 0 ? QString("+%1").arg(delta) : QString::number(delta); +} + +QString countChangeSummary(const QString& label, int baseline, int variant) { + const int delta = variant - baseline; + if (delta == 0) { + return QString("%1 details changed").arg(label); + } + return QString("%1 %2 (%3 -> %4)").arg(label, signedDelta(delta)).arg(baseline).arg(variant); +} + +QString boolValue(bool value) { + return value ? "on" : "off"; +} + +QString buildChangeSummaryLine( + const safecrowd::domain::ScenarioDraft& baseline, + const safecrowd::domain::ScenarioDraft& variant, + const std::string& key) { + if (key == "population.placements") { + const auto baselinePlacements = static_cast(baseline.population.initialPlacements.size()); + const auto variantPlacements = static_cast(variant.population.initialPlacements.size()); + QStringList parts; + const int occupantDelta = draftOccupantCount(variant) - draftOccupantCount(baseline); + if (occupantDelta != 0) { + parts << QString("%1 occupants").arg(signedDelta(occupantDelta)); + } + if (baselinePlacements != variantPlacements) { + parts << countChangeSummary("placements", baselinePlacements, variantPlacements); + } + if (parts.isEmpty()) { + parts << "placement details changed"; + } + return QString("population.placements (%1)").arg(parts.join(", ")); + } + if (key == "environment.reducedVisibility") { + return QString("environment.reducedVisibility (%1 -> %2)") + .arg(boolValue(baseline.environment.reducedVisibility), boolValue(variant.environment.reducedVisibility)); + } + if (key == "environment.familiarityProfile") { + return QString("environment.familiarityProfile (%1 -> %2)") + .arg(QString::fromStdString(baseline.environment.familiarityProfile), + QString::fromStdString(variant.environment.familiarityProfile)); + } + if (key == "environment.guidanceProfile") { + return QString("environment.guidanceProfile (%1 -> %2)") + .arg(QString::fromStdString(baseline.environment.guidanceProfile), + QString::fromStdString(variant.environment.guidanceProfile)); + } + if (key == "control.events") { + return QString("control.events (%1)") + .arg(countChangeSummary("events", static_cast(baseline.control.events.size()), + static_cast(variant.control.events.size()))); + } + if (key == "control.connectionBlocks") { + return QString("control.connectionBlocks (%1)") + .arg(countChangeSummary("blocks", static_cast(baseline.control.connectionBlocks.size()), + static_cast(variant.control.connectionBlocks.size()))); + } + if (key == "execution.timeLimit") { + return QString("execution.timeLimit (%1s -> %2s)") + .arg(baseline.execution.timeLimitSeconds, 0, 'f', 1) + .arg(variant.execution.timeLimitSeconds, 0, 'f', 1); + } + if (key == "execution.sampleInterval") { + return QString("execution.sampleInterval (%1s -> %2s)") + .arg(baseline.execution.sampleIntervalSeconds, 0, 'f', 1) + .arg(variant.execution.sampleIntervalSeconds, 0, 'f', 1); + } + if (key == "execution.repeatCount") { + return QString("execution.repeatCount (%1 -> %2)") + .arg(baseline.execution.repeatCount) + .arg(variant.execution.repeatCount); + } + if (key == "execution.baseSeed") { + return QString("execution.baseSeed (%1 -> %2)") + .arg(baseline.execution.baseSeed) + .arg(variant.execution.baseSeed); + } + if (key == "execution.recordOccupantHistory") { + return QString("execution.recordOccupantHistory (%1 -> %2)") + .arg(boolValue(baseline.execution.recordOccupantHistory), + boolValue(variant.execution.recordOccupantHistory)); + } + return QString::fromStdString(key); +} + +QString changeCategoryLabel(const std::string& key) { + if (key.rfind("population.", 0) == 0) { + return "Crowd"; + } + if (key.rfind("environment.", 0) == 0) { + return "Layout"; + } + if (key.rfind("control.", 0) == 0) { + return "Events"; + } + if (key.rfind("execution.", 0) == 0) { + return "Run"; + } + return "Change"; +} + +QString compactChangeSummary(const QString& summary) { + auto compact = summary; + compact.replace("population.placements", "crowd placements"); + compact.replace("environment.reducedVisibility", "layout visibility"); + compact.replace("environment.familiarityProfile", "layout familiarity"); + compact.replace("environment.guidanceProfile", "layout guidance"); + compact.replace("control.events", "events"); + compact.replace("control.connectionBlocks", "blocked events"); + compact.replace("execution.timeLimit", "run time limit"); + compact.replace("execution.sampleInterval", "run sample interval"); + compact.replace("execution.repeatCount", "run repeat count"); + compact.replace("execution.baseSeed", "run base seed"); + compact.replace("execution.recordOccupantHistory", "run occupant history"); + return compact; +} + +QStringList buildChangeSummaryLines( + const safecrowd::domain::ScenarioDraft& baseline, + const safecrowd::domain::ScenarioDraft& variant) { + QStringList changes; + for (const auto& key : variant.variationDiffKeys) { + changes << buildChangeSummaryLine(baseline, variant, key); + } + return changes; +} + +void clearLayout(QLayout* layout) { + if (layout == nullptr) { + return; + } + while (auto* item = layout->takeAt(0)) { + if (auto* widget = item->widget()) { + widget->deleteLater(); + } + delete item; + } +} + +QFrame* createInspectorCard(QWidget* parent) { + auto* card = new QFrame(parent); + card->setStyleSheet( + "QFrame { background: #ffffff; border: 1px solid #d7e0ea; border-radius: 12px; }" + "QLabel { background: transparent; border: 0; }"); + card->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + return card; +} + +QLabel* createRoleBadge(const QString& text, bool alternative, QWidget* parent) { + auto* badge = createLabel(text, parent, ui::FontRole::Caption); + badge->setAlignment(Qt::AlignCenter); + badge->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); + badge->setStyleSheet(alternative + ? "QLabel { background: #fff7ed; border: 1px solid #fed7aa; border-radius: 9px; color: #9a3412; padding: 3px 8px; }" + : "QLabel { background: #e6eef8; border: 1px solid #b8c6d6; border-radius: 9px; color: #1f5fae; padding: 3px 8px; }"); + return badge; +} + +void addMetaRow(QVBoxLayout* layout, const QString& label, const QString& value, QWidget* parent) { + auto* row = new QWidget(parent); + auto* rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 0, 0, 0); + rowLayout->setSpacing(8); + + auto* labelWidget = createLabel(label, row, ui::FontRole::Caption); + labelWidget->setStyleSheet(ui::subtleTextStyleSheet()); + labelWidget->setMinimumWidth(62); + labelWidget->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + auto* valueWidget = createLabel(value.isEmpty() ? "-" : value, row, ui::FontRole::Body); + valueWidget->setStyleSheet(ui::mutedTextStyleSheet()); + valueWidget->setMinimumWidth(0); + valueWidget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + + rowLayout->addWidget(labelWidget); + rowLayout->addWidget(valueWidget, 1); + layout->addWidget(row); +} + +void addStatusMessage(QVBoxLayout* layout, const QString& text, QWidget* parent) { + auto* message = createLabel(text, parent, ui::FontRole::Body); + message->setStyleSheet(ui::mutedTextStyleSheet()); + layout->addWidget(message); +} + +void addDiffRow(QVBoxLayout* layout, const QString& category, const QString& summary, QWidget* parent) { + auto* row = new QFrame(parent); + row->setStyleSheet( + "QFrame { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; }" + "QLabel { background: transparent; border: 0; }"); + auto* rowLayout = new QVBoxLayout(row); + rowLayout->setContentsMargins(7, 6, 7, 7); + rowLayout->setSpacing(5); + + auto* categoryBadge = createLabel(category, row, ui::FontRole::Caption); + categoryBadge->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + categoryBadge->setStyleSheet( + "QLabel { background: #e6eef8; border: 1px solid #c9d5e2; border-radius: 8px; color: #1f5fae; padding: 3px 7px; }"); + categoryBadge->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred); + + auto* summaryLabel = createLabel(summary, row, ui::FontRole::Caption); + summaryLabel->setStyleSheet(ui::mutedTextStyleSheet()); + summaryLabel->setMinimumWidth(0); + summaryLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + + rowLayout->addWidget(categoryBadge); + rowLayout->addWidget(summaryLabel); + layout->addWidget(row); +} + int totalOccupantCount(const ScenarioAuthoringWidget::ScenarioState& scenario) { int total = 0; for (const auto& placement : scenario.crowdPlacements) { @@ -451,6 +672,7 @@ void ScenarioAuthoringWidget::addEventDraft(const QString& name, const QString& .targetSummary = target.toStdString(), }); scenario->draft.control.events = scenario->events; + recomputeDiffKeysAfterScenarioChanged(*scenario); refreshNavigationPanel(); refreshInspector(); } @@ -465,12 +687,19 @@ void ScenarioAuthoringWidget::createScenarioWithName(const QString& name, int so return; } + const auto newScenarioId = QString("scenario-%1").arg(scenarios_.size() + 1).toStdString(); ScenarioState scenario; if (sourceIndex >= 0 && sourceIndex < static_cast(scenarios_.size())) { - scenario = scenarios_[sourceIndex]; - scenario.baseScenarioId = QString::fromStdString(scenarios_[sourceIndex].draft.scenarioId); - scenario.draft.role = safecrowd::domain::ScenarioRole::Alternative; - scenario.draft.variationDiffKeys = {"branch.duplicated"}; + const auto& source = scenarios_[sourceIndex]; + scenario.draft = safecrowd::domain::duplicateScenarioDraft( + source.draft, newScenarioId, trimmedName.toStdString()); + scenario.events = source.events; + scenario.crowdPlacements = source.crowdPlacements; + scenario.startText = source.startText; + scenario.destinationText = source.destinationText; + scenario.baseScenarioId = source.draft.role == safecrowd::domain::ScenarioRole::Alternative + ? source.baseScenarioId + : QString::fromStdString(source.draft.scenarioId); scenario.stagedForRun = false; } else { scenario.draft.role = safecrowd::domain::ScenarioRole::Baseline; @@ -479,6 +708,8 @@ void ScenarioAuthoringWidget::createScenarioWithName(const QString& name, int so scenario.draft.execution.sampleIntervalSeconds = 1.0; scenario.draft.execution.repeatCount = 1; scenario.draft.execution.baseSeed = 1; + scenario.draft.name = trimmedName.toStdString(); + scenario.draft.scenarioId = newScenarioId; const auto* destinationZone = firstDestinationZone(layout_); const auto* startZone = firstStartZone(layout_); @@ -490,10 +721,9 @@ void ScenarioAuthoringWidget::createScenarioWithName(const QString& name, int so } } - scenario.draft.name = trimmedName.toStdString(); - scenario.draft.scenarioId = QString("scenario-%1").arg(scenarios_.size() + 1).toStdString(); scenarios_.push_back(std::move(scenario)); currentScenarioIndex_ = static_cast(scenarios_.size()) - 1; + recomputeVariationDiffKeysIfAlternative(scenarios_.back()); refreshScenarioSwitcher(); refreshCanvas(); refreshNavigationPanel(); @@ -555,6 +785,7 @@ void ScenarioAuthoringWidget::refreshCanvas() { return; } current->draft.control.connectionBlocks = blocks; + recomputeDiffKeysAfterScenarioChanged(*current); refreshNavigationPanel(); refreshInspector(); }); @@ -570,40 +801,74 @@ void ScenarioAuthoringWidget::refreshInspector() { const auto* scenario = currentScenario(); const bool hasScenario = scenario != nullptr; - if (scenarioSummaryLabel_ != nullptr) { - if (!hasScenario) { - scenarioSummaryLabel_->setText("No scenario selected"); - } else { - const int people = totalOccupantCount(*scenario); - const auto blockCount = static_cast(scenario->draft.control.connectionBlocks.size()); - scenarioSummaryLabel_->setText(QString("Name: %1\nRole: %2\nPopulation: %3\nStart: %4\nDestination: %5\nEvents: %6\nBlocked exits: %7") - .arg( - QString::fromStdString(scenario->draft.name), - scenario->draft.role == safecrowd::domain::ScenarioRole::Baseline ? "Baseline" : "Alternative") - .arg(people) - .arg(scenario->startText, scenario->destinationText) - .arg(static_cast(scenario->events.size())) - .arg(blockCount)); + if (scenarioOverviewPanel_ != nullptr) { + auto* panelLayout = qobject_cast(scenarioOverviewPanel_->layout()); + clearLayout(panelLayout); + if (panelLayout != nullptr) { + auto* card = createInspectorCard(scenarioOverviewPanel_); + auto* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(12, 11, 12, 11); + cardLayout->setSpacing(8); + + if (!hasScenario) { + addStatusMessage(cardLayout, "No scenario selected", card); + } else { + const bool alternative = scenario->draft.role == safecrowd::domain::ScenarioRole::Alternative; + cardLayout->addWidget(createRoleBadge(alternative ? "Alternative" : "Baseline", alternative, card)); + + auto* nameLabel = createLabel(QString::fromStdString(scenario->draft.name), card, ui::FontRole::SectionTitle); + nameLabel->setMinimumWidth(0); + nameLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + cardLayout->addWidget(nameLabel); + + addMetaRow(cardLayout, "Population", QString::number(totalOccupantCount(*scenario)), card); + addMetaRow(cardLayout, "Events", QString::number(static_cast(scenario->events.size())), card); + addMetaRow(cardLayout, "Blocked", QString::number(static_cast(scenario->draft.control.connectionBlocks.size())), card); + addMetaRow(cardLayout, "Start", scenario->startText, card); + addMetaRow(cardLayout, "Destination", scenario->destinationText, card); + if (alternative && !scenario->baseScenarioId.isEmpty()) { + addMetaRow(cardLayout, "Based on", scenario->baseScenarioId, card); + } + } + panelLayout->addWidget(card); } } - if (changesLabel_ != nullptr) { - if (!hasScenario || scenario->baseScenarioId.isEmpty()) { - changesLabel_->setText("Changes from baseline: none"); - } else { - QStringList changes; - if (!scenario->events.empty()) { - changes << QString("Events: %1 configured").arg(static_cast(scenario->events.size())); - } - if (!scenario->draft.control.connectionBlocks.empty()) { - changes << QString("Blocked exits: %1 configured") - .arg(static_cast(scenario->draft.control.connectionBlocks.size())); - } - if (changes.isEmpty()) { - changes << "No changed fields yet"; + if (scenarioDiffPanel_ != nullptr) { + auto* panelLayout = qobject_cast(scenarioDiffPanel_->layout()); + clearLayout(panelLayout); + if (panelLayout != nullptr) { + auto* card = createInspectorCard(scenarioDiffPanel_); + auto* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(10, 10, 10, 10); + cardLayout->setSpacing(7); + + auto* title = createLabel("Changes", card, ui::FontRole::SectionTitle); + cardLayout->addWidget(title); + + if (!hasScenario) { + addStatusMessage(cardLayout, "No scenario selected", card); + } else if (scenario->draft.role == safecrowd::domain::ScenarioRole::Baseline) { + addStatusMessage(cardLayout, "Baseline scenario", card); + } else if (scenario->baseScenarioId.isEmpty()) { + addStatusMessage(cardLayout, "Alternative scenario / no baseline link", card); + } else { + const auto baseId = scenario->baseScenarioId.toStdString(); + const auto baselineIt = std::find_if(scenarios_.begin(), scenarios_.end(), [&](const auto& candidate) { + return candidate.draft.scenarioId == baseId; + }); + if (scenario->draft.variationDiffKeys.empty()) { + addStatusMessage(cardLayout, "No changed fields yet", card); + } else { + for (const auto& key : scenario->draft.variationDiffKeys) { + const auto summary = baselineIt != scenarios_.end() + ? buildChangeSummaryLine(baselineIt->draft, scenario->draft, key) + : QString::fromStdString(key); + addDiffRow(cardLayout, changeCategoryLabel(key), compactChangeSummary(summary), card); + } + } } - changesLabel_->setText(QString("Based on: %1\nChanged:\n- %2") - .arg(scenario->baseScenarioId, changes.join("\n- "))); + panelLayout->addWidget(card); } } @@ -747,8 +1012,8 @@ void ScenarioAuthoringWidget::refreshNavigationPanel() { void ScenarioAuthoringWidget::refreshRightPanel() { scenarioSwitcher_ = nullptr; - scenarioSummaryLabel_ = nullptr; - changesLabel_ = nullptr; + scenarioOverviewPanel_ = nullptr; + scenarioDiffPanel_ = nullptr; newScenarioButton_ = nullptr; stageScenarioButton_ = nullptr; stagedScenariosLabel_ = nullptr; @@ -852,10 +1117,46 @@ void ScenarioAuthoringWidget::updateCurrentScenarioPlacements(const std::vector< scenario->stagedForRun = false; } + recomputeDiffKeysAfterScenarioChanged(*scenario); refreshNavigationPanel(); refreshInspector(); } +void ScenarioAuthoringWidget::recomputeDiffKeysAfterScenarioChanged(ScenarioState& scenario) { + recomputeVariationDiffKeysIfAlternative(scenario); + if (scenario.draft.role == safecrowd::domain::ScenarioRole::Baseline) { + recomputeDependentVariationDiffKeys(QString::fromStdString(scenario.draft.scenarioId)); + } +} + +void ScenarioAuthoringWidget::recomputeDependentVariationDiffKeys(const QString& baselineId) { + if (baselineId.isEmpty()) { + return; + } + for (auto& scenario : scenarios_) { + if (scenario.baseScenarioId == baselineId) { + recomputeVariationDiffKeysIfAlternative(scenario); + } + } +} + +void ScenarioAuthoringWidget::recomputeVariationDiffKeysIfAlternative(ScenarioState& scenario) const { + if (scenario.draft.role != safecrowd::domain::ScenarioRole::Alternative + || scenario.baseScenarioId.isEmpty()) { + scenario.draft.variationDiffKeys.clear(); + return; + } + const auto baseId = scenario.baseScenarioId.toStdString(); + for (const auto& candidate : scenarios_) { + if (candidate.draft.scenarioId == baseId) { + scenario.draft.variationDiffKeys = + safecrowd::domain::computeScenarioDiffKeys(candidate.draft, scenario.draft); + return; + } + } + scenario.draft.variationDiffKeys.clear(); +} + void ScenarioAuthoringWidget::showEmptyCanvas() { auto* canvas = new QWidget(shell_); canvas->setStyleSheet("QWidget { background: #f4f7fb; }"); @@ -924,13 +1225,17 @@ QWidget* ScenarioAuthoringWidget::createScenarioPanel() { newScenarioButton_->setStyleSheet(ui::secondaryButtonStyleSheet()); inspectorLayout->addWidget(newScenarioButton_); - scenarioSummaryLabel_ = createLabel("", inspector); - scenarioSummaryLabel_->setStyleSheet(ui::mutedTextStyleSheet()); - inspectorLayout->addWidget(scenarioSummaryLabel_); - - changesLabel_ = createLabel("", inspector); - changesLabel_->setStyleSheet(ui::mutedTextStyleSheet()); - inspectorLayout->addWidget(changesLabel_); + scenarioOverviewPanel_ = new QWidget(inspector); + auto* overviewLayout = new QVBoxLayout(scenarioOverviewPanel_); + overviewLayout->setContentsMargins(0, 0, 0, 0); + overviewLayout->setSpacing(0); + inspectorLayout->addWidget(scenarioOverviewPanel_); + + scenarioDiffPanel_ = new QWidget(inspector); + auto* diffLayout = new QVBoxLayout(scenarioDiffPanel_); + diffLayout->setContentsMargins(0, 0, 0, 0); + diffLayout->setSpacing(0); + inspectorLayout->addWidget(scenarioDiffPanel_); stageScenarioButton_ = new QPushButton("Stage Scenario", inspector); stageScenarioButton_->setFont(ui::font(ui::FontRole::Body)); diff --git a/src/application/ScenarioAuthoringWidget.h b/src/application/ScenarioAuthoringWidget.h index 006b00c..419142d 100644 --- a/src/application/ScenarioAuthoringWidget.h +++ b/src/application/ScenarioAuthoringWidget.h @@ -81,6 +81,9 @@ class ScenarioAuthoringWidget : public QWidget { void refreshNavigationPanel(); void refreshRightPanel(); void refreshScenarioSwitcher(); + void recomputeDiffKeysAfterScenarioChanged(ScenarioState& scenario); + void recomputeDependentVariationDiffKeys(const QString& baselineId); + void recomputeVariationDiffKeysIfAlternative(ScenarioState& scenario) const; void runFirstStagedBaselineScenario(); void stageCurrentScenario(); void updateCurrentScenarioPlacements(const std::vector& placements); @@ -107,8 +110,8 @@ class ScenarioAuthoringWidget : public QWidget { WorkspaceShell* shell_{nullptr}; ScenarioCanvasWidget* canvas_{nullptr}; QComboBox* scenarioSwitcher_{nullptr}; - QLabel* scenarioSummaryLabel_{nullptr}; - QLabel* changesLabel_{nullptr}; + QWidget* scenarioOverviewPanel_{nullptr}; + QWidget* scenarioDiffPanel_{nullptr}; QLabel* stagedScenariosLabel_{nullptr}; QPushButton* newScenarioButton_{nullptr}; QPushButton* stageScenarioButton_{nullptr}; diff --git a/src/application/ScenarioResultWidget.cpp b/src/application/ScenarioResultWidget.cpp index bdb2094..f00a6e1 100644 --- a/src/application/ScenarioResultWidget.cpp +++ b/src/application/ScenarioResultWidget.cpp @@ -1281,6 +1281,7 @@ ScenarioResultWidget::ScenarioResultWidget( std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::optional returnAuthoringState, QWidget* parent) : QWidget(parent), projectName_(std::move(projectName)), @@ -1289,6 +1290,7 @@ ScenarioResultWidget::ScenarioResultWidget( frame_(std::move(frame)), risk_(std::move(risk)), artifacts_(std::move(artifacts)), + returnAuthoringState_(std::move(returnAuthoringState)), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { @@ -1462,7 +1464,8 @@ void ScenarioResultWidget::rerunScenario() { frame_, risk_, artifacts_, - this); + this, + returnAuthoringState_); rootLayout->replaceWidget(shell_, runWidget); shell_->hide(); @@ -1476,10 +1479,12 @@ void ScenarioResultWidget::navigateToAuthoring(bool showRunPanel) { return; } - ScenarioAuthoringWidget::InitialState initial; - initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); - initial.currentScenarioIndex = 0; - initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; + auto initial = returnAuthoringState_.value_or(ScenarioAuthoringWidget::InitialState{}); + if (initial.scenarios.empty()) { + initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); + initial.currentScenarioIndex = 0; + initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; + } initial.rightPanelMode = showRunPanel ? ScenarioAuthoringWidget::RightPanelMode::Run : ScenarioAuthoringWidget::RightPanelMode::Scenario; diff --git a/src/application/ScenarioResultWidget.h b/src/application/ScenarioResultWidget.h index 12205f9..26abe32 100644 --- a/src/application/ScenarioResultWidget.h +++ b/src/application/ScenarioResultWidget.h @@ -2,10 +2,12 @@ #include #include +#include #include #include +#include "application/ScenarioAuthoringWidget.h" #include "domain/FacilityLayout2D.h" #include "domain/ScenarioAuthoring.h" #include "domain/ScenarioResultArtifacts.h" @@ -28,6 +30,7 @@ class ScenarioResultWidget : public QWidget { std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, + std::optional returnAuthoringState = std::nullopt, QWidget* parent = nullptr); const safecrowd::domain::ScenarioDraft& scenario() const noexcept; @@ -53,6 +56,7 @@ class ScenarioResultWidget : public QWidget { safecrowd::domain::SimulationFrame frame_{}; safecrowd::domain::ScenarioRiskSnapshot risk_{}; safecrowd::domain::ScenarioResultArtifacts artifacts_{}; + std::optional returnAuthoringState_{}; std::function saveProjectHandler_{}; std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; diff --git a/src/application/ScenarioRunWidget.cpp b/src/application/ScenarioRunWidget.cpp index 9915d14..af95b2d 100644 --- a/src/application/ScenarioRunWidget.cpp +++ b/src/application/ScenarioRunWidget.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -177,7 +178,8 @@ ScenarioRunWidget::ScenarioRunWidget( std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, - QWidget* parent) + QWidget* parent, + std::optional returnAuthoringState) : QWidget(parent), projectName_(projectName), layout_(layout), @@ -185,7 +187,8 @@ ScenarioRunWidget::ScenarioRunWidget( runner_(layout_, scenario_), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), - backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)), + returnAuthoringState_(std::move(returnAuthoringState)) { setupUi(); } @@ -199,7 +202,8 @@ ScenarioRunWidget::ScenarioRunWidget( safecrowd::domain::SimulationFrame cachedResultFrame, safecrowd::domain::ScenarioRiskSnapshot cachedResultRisk, safecrowd::domain::ScenarioResultArtifacts cachedResultArtifacts, - QWidget* parent) + QWidget* parent, + std::optional returnAuthoringState) : QWidget(parent), projectName_(projectName), layout_(layout), @@ -210,7 +214,8 @@ ScenarioRunWidget::ScenarioRunWidget( cachedResultArtifacts_(std::move(cachedResultArtifacts)), saveProjectHandler_(std::move(saveProjectHandler)), openProjectHandler_(std::move(openProjectHandler)), - backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)) { + backToLayoutReviewHandler_(std::move(backToLayoutReviewHandler)), + returnAuthoringState_(std::move(returnAuthoringState)) { setupUi(); } @@ -352,10 +357,12 @@ void ScenarioRunWidget::returnToAuthoring() { return; } - ScenarioAuthoringWidget::InitialState initial; - initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); - initial.currentScenarioIndex = 0; - initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; + auto initial = returnAuthoringState_.value_or(ScenarioAuthoringWidget::InitialState{}); + if (initial.scenarios.empty()) { + initial.scenarios.push_back(scenarioStateFromDraft(scenario_, layout_)); + initial.currentScenarioIndex = 0; + initial.navigationView = ScenarioAuthoringWidget::NavigationView::Layout; + } initial.rightPanelMode = ScenarioAuthoringWidget::RightPanelMode::Scenario; auto* authoringWidget = new ScenarioAuthoringWidget( @@ -590,6 +597,7 @@ void ScenarioRunWidget::showResults() { } }, backToLayoutReviewHandler_, + returnAuthoringState_, this); rootLayout->replaceWidget(shell_, resultWidget); shell_->hide(); diff --git a/src/application/ScenarioRunWidget.h b/src/application/ScenarioRunWidget.h index ad6b0bf..91f587f 100644 --- a/src/application/ScenarioRunWidget.h +++ b/src/application/ScenarioRunWidget.h @@ -6,6 +6,7 @@ #include #include +#include "application/ScenarioAuthoringWidget.h" #include "domain/FacilityLayout2D.h" #include "domain/ScenarioAuthoring.h" #include "domain/ScenarioSimulationRunner.h" @@ -29,7 +30,8 @@ class ScenarioRunWidget : public QWidget { std::function saveProjectHandler, std::function openProjectHandler, std::function backToLayoutReviewHandler, - QWidget* parent = nullptr); + QWidget* parent = nullptr, + std::optional returnAuthoringState = std::nullopt); explicit ScenarioRunWidget( const QString& projectName, const safecrowd::domain::FacilityLayout2D& layout, @@ -40,7 +42,8 @@ class ScenarioRunWidget : public QWidget { safecrowd::domain::SimulationFrame cachedResultFrame, safecrowd::domain::ScenarioRiskSnapshot cachedResultRisk, safecrowd::domain::ScenarioResultArtifacts cachedResultArtifacts, - QWidget* parent = nullptr); + QWidget* parent = nullptr, + std::optional returnAuthoringState = std::nullopt); const safecrowd::domain::ScenarioDraft& scenario() const noexcept; @@ -67,6 +70,7 @@ class ScenarioRunWidget : public QWidget { std::function saveProjectHandler_{}; std::function openProjectHandler_{}; std::function backToLayoutReviewHandler_{}; + std::optional returnAuthoringState_{}; WorkspaceShell* shell_{nullptr}; SimulationCanvasWidget* canvas_{nullptr}; QTimer* timer_{nullptr}; diff --git a/src/domain/DemoFixtureService.cpp b/src/domain/DemoFixtureService.cpp index 3bade0c..4c8f859 100644 --- a/src/domain/DemoFixtureService.cpp +++ b/src/domain/DemoFixtureService.cpp @@ -22,6 +22,16 @@ DemoFixture DemoFixtureService::createSprint1DemoFixture() const { .targetAgentCount = 100, }); + fixture.baselineScenario.scenarioId = "scenario-1"; + fixture.baselineScenario.name = "Sprint 1 baseline"; + fixture.baselineScenario.role = ScenarioRole::Baseline; + fixture.baselineScenario.population = fixture.population; + fixture.baselineScenario.execution.timeLimitSeconds = 600.0; + fixture.baselineScenario.execution.sampleIntervalSeconds = 1.0; + fixture.baselineScenario.execution.repeatCount = 1; + fixture.baselineScenario.execution.baseSeed = 1; + fixture.baselineScenario.sourceTemplateId = "after-sprint-1-baseline"; + return fixture; } diff --git a/src/domain/DemoFixtureService.h b/src/domain/DemoFixtureService.h index 228da40..14ed6ce 100644 --- a/src/domain/DemoFixtureService.h +++ b/src/domain/DemoFixtureService.h @@ -2,12 +2,14 @@ #include "domain/FacilityLayout2D.h" #include "domain/PopulationSpec.h" +#include "domain/ScenarioAuthoring.h" namespace safecrowd::domain { struct DemoFixture { FacilityLayout2D layout{}; PopulationSpec population{}; + ScenarioDraft baselineScenario{}; }; class DemoFixtureService { diff --git a/src/domain/ScenarioAuthoring.cpp b/src/domain/ScenarioAuthoring.cpp new file mode 100644 index 0000000..10ab5fa --- /dev/null +++ b/src/domain/ScenarioAuthoring.cpp @@ -0,0 +1,153 @@ +#include "domain/ScenarioAuthoring.h" + +#include + +namespace safecrowd::domain { + +namespace { + +bool pointsEqual(const Point2D& lhs, const Point2D& rhs) { + return lhs.x == rhs.x && lhs.y == rhs.y; +} + +bool pointVectorsEqual(const std::vector& lhs, const std::vector& rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + for (std::size_t i = 0; i < lhs.size(); ++i) { + if (!pointsEqual(lhs[i], rhs[i])) { + return false; + } + } + return true; +} + +bool polygonsEqual(const Polygon2D& lhs, const Polygon2D& rhs) { + if (!pointVectorsEqual(lhs.outline, rhs.outline) || lhs.holes.size() != rhs.holes.size()) { + return false; + } + for (std::size_t i = 0; i < lhs.holes.size(); ++i) { + if (!pointVectorsEqual(lhs.holes[i], rhs.holes[i])) { + return false; + } + } + return true; +} + +bool placementsEqual(const InitialPlacement2D& lhs, const InitialPlacement2D& rhs) { + if (lhs.id != rhs.id || lhs.zoneId != rhs.zoneId || lhs.floorId != rhs.floorId + || lhs.targetAgentCount != rhs.targetAgentCount + || !pointsEqual(lhs.initialVelocity, rhs.initialVelocity) + || lhs.distribution != rhs.distribution + || !polygonsEqual(lhs.area, rhs.area) + || !pointVectorsEqual(lhs.explicitPositions, rhs.explicitPositions)) { + return false; + } + return true; +} + +bool populationsEqual(const PopulationSpec& lhs, const PopulationSpec& rhs) { + if (lhs.initialPlacements.size() != rhs.initialPlacements.size()) { + return false; + } + for (std::size_t i = 0; i < lhs.initialPlacements.size(); ++i) { + if (!placementsEqual(lhs.initialPlacements[i], rhs.initialPlacements[i])) { + return false; + } + } + return true; +} + +bool eventsEqual(const std::vector& lhs, + const std::vector& rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + for (std::size_t i = 0; i < lhs.size(); ++i) { + if (lhs[i].id != rhs[i].id || lhs[i].name != rhs[i].name + || lhs[i].triggerSummary != rhs[i].triggerSummary + || lhs[i].targetSummary != rhs[i].targetSummary) { + return false; + } + } + return true; +} + +bool connectionBlocksEqual(const std::vector& lhs, + const std::vector& rhs) { + if (lhs.size() != rhs.size()) { + return false; + } + for (std::size_t i = 0; i < lhs.size(); ++i) { + if (lhs[i].id != rhs[i].id || lhs[i].connectionId != rhs[i].connectionId) { + return false; + } + if (lhs[i].intervals.size() != rhs[i].intervals.size()) { + return false; + } + for (std::size_t j = 0; j < lhs[i].intervals.size(); ++j) { + if (lhs[i].intervals[j].startSeconds != rhs[i].intervals[j].startSeconds + || lhs[i].intervals[j].endSeconds != rhs[i].intervals[j].endSeconds) { + return false; + } + } + } + return true; +} + +} // namespace + +ScenarioDraft duplicateScenarioDraft(const ScenarioDraft& source, + std::string newScenarioId, + std::string newName) { + ScenarioDraft copy = source; + copy.scenarioId = std::move(newScenarioId); + copy.name = std::move(newName); + copy.role = ScenarioRole::Alternative; + copy.variationDiffKeys.clear(); + copy.blockingIssues.clear(); + return copy; +} + +std::vector computeScenarioDiffKeys(const ScenarioDraft& baseline, + const ScenarioDraft& variant) { + std::vector keys; + + if (!populationsEqual(baseline.population, variant.population)) { + keys.emplace_back("population.placements"); + } + if (baseline.environment.reducedVisibility != variant.environment.reducedVisibility) { + keys.emplace_back("environment.reducedVisibility"); + } + if (baseline.environment.familiarityProfile != variant.environment.familiarityProfile) { + keys.emplace_back("environment.familiarityProfile"); + } + if (baseline.environment.guidanceProfile != variant.environment.guidanceProfile) { + keys.emplace_back("environment.guidanceProfile"); + } + if (!eventsEqual(baseline.control.events, variant.control.events)) { + keys.emplace_back("control.events"); + } + if (!connectionBlocksEqual(baseline.control.connectionBlocks, variant.control.connectionBlocks)) { + keys.emplace_back("control.connectionBlocks"); + } + if (baseline.execution.timeLimitSeconds != variant.execution.timeLimitSeconds) { + keys.emplace_back("execution.timeLimit"); + } + if (baseline.execution.sampleIntervalSeconds != variant.execution.sampleIntervalSeconds) { + keys.emplace_back("execution.sampleInterval"); + } + if (baseline.execution.repeatCount != variant.execution.repeatCount) { + keys.emplace_back("execution.repeatCount"); + } + if (baseline.execution.baseSeed != variant.execution.baseSeed) { + keys.emplace_back("execution.baseSeed"); + } + if (baseline.execution.recordOccupantHistory != variant.execution.recordOccupantHistory) { + keys.emplace_back("execution.recordOccupantHistory"); + } + + return keys; +} + +} // namespace safecrowd::domain diff --git a/src/domain/ScenarioAuthoring.h b/src/domain/ScenarioAuthoring.h index d10bace..d16432e 100644 --- a/src/domain/ScenarioAuthoring.h +++ b/src/domain/ScenarioAuthoring.h @@ -72,4 +72,11 @@ struct ProjectWorkspaceSnapshot { std::vector scenarios{}; }; +ScenarioDraft duplicateScenarioDraft(const ScenarioDraft& source, + std::string newScenarioId, + std::string newName); + +std::vector computeScenarioDiffKeys(const ScenarioDraft& baseline, + const ScenarioDraft& variant); + } // namespace safecrowd::domain diff --git a/tests/DemoFixtureServiceTests.cpp b/tests/DemoFixtureServiceTests.cpp index a92fc34..c9a832c 100644 --- a/tests/DemoFixtureServiceTests.cpp +++ b/tests/DemoFixtureServiceTests.cpp @@ -110,6 +110,12 @@ SC_TEST(DemoFixtureServiceBuildsSprint1Fixture) { SC_EXPECT_EQ(population.initialPlacements.front().zoneId, std::string(safecrowd::domain::DemoLayouts::Sprint1FacilityIds::MainRoomZoneId)); SC_EXPECT_EQ(population.initialPlacements.front().targetAgentCount, std::size_t{100}); SC_EXPECT_EQ(population.initialPlacements.front().area.outline.size(), std::size_t{4}); + SC_EXPECT_EQ(fixture.baselineScenario.scenarioId, std::string("scenario-1")); + SC_EXPECT_EQ(fixture.baselineScenario.name, std::string("Sprint 1 baseline")); + SC_EXPECT_EQ(fixture.baselineScenario.role, safecrowd::domain::ScenarioRole::Baseline); + SC_EXPECT_EQ(fixture.baselineScenario.population.initialPlacements.size(), std::size_t{1}); + SC_EXPECT_EQ(fixture.baselineScenario.population.initialPlacements.front().targetAgentCount, std::size_t{100}); + SC_EXPECT_EQ(fixture.baselineScenario.execution.timeLimitSeconds, 600.0); safecrowd::domain::ImportValidationService validator; const auto issues = validator.validate(layout); diff --git a/tests/ScenarioAuthoringTests.cpp b/tests/ScenarioAuthoringTests.cpp new file mode 100644 index 0000000..57edf74 --- /dev/null +++ b/tests/ScenarioAuthoringTests.cpp @@ -0,0 +1,213 @@ +#include "TestSupport.h" +#include "domain/ScenarioAuthoring.h" + +#include + +using namespace safecrowd::domain; +using safecrowd::tests::TestFailure; + +namespace { + +ScenarioDraft makeBaselineDraft() { + ScenarioDraft draft; + draft.scenarioId = "scenario-baseline"; + draft.name = "Baseline"; + draft.role = ScenarioRole::Baseline; + draft.execution.timeLimitSeconds = 600.0; + draft.execution.sampleIntervalSeconds = 1.0; + draft.execution.repeatCount = 1; + draft.execution.baseSeed = 42; + draft.environment.familiarityProfile = "office"; + draft.environment.guidanceProfile = "trained"; + InitialPlacement2D placement; + placement.id = "placement-1"; + placement.zoneId = "zone-a"; + placement.targetAgentCount = 100; + draft.population.initialPlacements.push_back(placement); + OperationalEventDraft event; + event.id = "event-1"; + event.name = "Exit closed"; + draft.control.events.push_back(event); + return draft; +} + +bool containsKey(const std::vector& keys, const std::string& key) { + return std::find(keys.begin(), keys.end(), key) != keys.end(); +} + +} // namespace + +SC_TEST(duplicateScenarioDraft_setsAlternativeRoleAndIdentity) { + const auto baseline = makeBaselineDraft(); + + const auto variant = duplicateScenarioDraft(baseline, "scenario-2", "My Alternative"); + + SC_EXPECT_TRUE(variant.role == ScenarioRole::Alternative); + SC_EXPECT_EQ(variant.scenarioId, std::string("scenario-2")); + SC_EXPECT_EQ(variant.name, std::string("My Alternative")); + SC_EXPECT_TRUE(variant.variationDiffKeys.empty()); + SC_EXPECT_TRUE(variant.blockingIssues.empty()); + SC_EXPECT_EQ(variant.population.initialPlacements.size(), baseline.population.initialPlacements.size()); + SC_EXPECT_EQ(variant.control.events.size(), baseline.control.events.size()); + SC_EXPECT_EQ(variant.execution.baseSeed, baseline.execution.baseSeed); +} + +SC_TEST(duplicateScenarioDraft_doesNotMutateSource) { + auto baseline = makeBaselineDraft(); + const auto originalEventCount = baseline.control.events.size(); + const auto originalPlacementCount = baseline.population.initialPlacements.size(); + const auto originalRole = baseline.role; + const auto originalId = baseline.scenarioId; + + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.control.events.clear(); + variant.population.initialPlacements.clear(); + variant.execution.timeLimitSeconds = 1.0; + + SC_EXPECT_EQ(baseline.control.events.size(), originalEventCount); + SC_EXPECT_EQ(baseline.population.initialPlacements.size(), originalPlacementCount); + SC_EXPECT_TRUE(baseline.role == originalRole); + SC_EXPECT_EQ(baseline.scenarioId, originalId); + SC_EXPECT_NEAR(baseline.execution.timeLimitSeconds, 600.0, 1e-9); +} + +SC_TEST(computeScenarioDiffKeys_returnsEmptyForFreshDuplicate) { + const auto baseline = makeBaselineDraft(); + const auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_TRUE(keys.empty()); +} + +SC_TEST(computeScenarioDiffKeys_detectsPopulationChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.population.initialPlacements[0].targetAgentCount = 250; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "population.placements")); +} + +SC_TEST(computeScenarioDiffKeys_detectsPlacementAreaChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.population.initialPlacements[0].area.outline.push_back({1.0, 2.0}); + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "population.placements")); +} + +SC_TEST(computeScenarioDiffKeys_detectsPlacementVelocityChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.population.initialPlacements[0].initialVelocity = {0.25, 0.5}; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "population.placements")); +} + +SC_TEST(computeScenarioDiffKeys_detectsEnvironmentChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.environment.reducedVisibility = true; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "environment.reducedVisibility")); +} + +SC_TEST(computeScenarioDiffKeys_detectsFamiliarityProfileChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.environment.familiarityProfile = "visitor"; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "environment.familiarityProfile")); +} + +SC_TEST(computeScenarioDiffKeys_detectsGuidanceProfileChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.environment.guidanceProfile = "untrained"; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "environment.guidanceProfile")); +} + +SC_TEST(computeScenarioDiffKeys_detectsControlEventsChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + OperationalEventDraft extra; + extra.id = "event-extra"; + extra.name = "Counterflow guidance"; + variant.control.events.push_back(extra); + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "control.events")); +} + +SC_TEST(computeScenarioDiffKeys_detectsExecutionChanges) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.execution.timeLimitSeconds = 900.0; + variant.execution.repeatCount = 5; + variant.execution.baseSeed = baseline.execution.baseSeed + 1; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_TRUE(containsKey(keys, "execution.timeLimit")); + SC_EXPECT_TRUE(containsKey(keys, "execution.repeatCount")); + SC_EXPECT_TRUE(containsKey(keys, "execution.baseSeed")); + SC_EXPECT_EQ(keys.size(), std::size_t{3}); +} + +SC_TEST(computeScenarioDiffKeys_detectsSampleIntervalChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.execution.sampleIntervalSeconds = 0.5; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "execution.sampleInterval")); +} + +SC_TEST(computeScenarioDiffKeys_detectsRecordOccupantHistoryChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + variant.execution.recordOccupantHistory = true; + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "execution.recordOccupantHistory")); +} + +SC_TEST(computeScenarioDiffKeys_detectsConnectionBlockChange) { + const auto baseline = makeBaselineDraft(); + auto variant = duplicateScenarioDraft(baseline, "scenario-2", "Variant"); + ConnectionBlockDraft block; + block.id = "block-1"; + block.connectionId = "door-east"; + block.intervals.push_back({0.0, 60.0}); + variant.control.connectionBlocks.push_back(block); + + const auto keys = computeScenarioDiffKeys(baseline, variant); + + SC_EXPECT_EQ(keys.size(), std::size_t{1}); + SC_EXPECT_TRUE(containsKey(keys, "control.connectionBlocks")); +}