From a24d4a22c18c9af7cac810cfa1ebb653a3524841 Mon Sep 17 00:00:00 2001 From: JieXiong9119 <69597597+JieXiong9119@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:23:52 -0600 Subject: [PATCH] add measure mapper for scenario workflow --- Gemfile.lock | 1 + docs/scenario_workflow_translation_plan.md | 41 ++-- lib/BOSS/constants.rb | 1 + lib/BOSS/scenario_measure_mapper.rb | 118 ++++++++++ spec/tests/unit/scenario_measure_map_spec.rb | 12 +- .../unit/scenario_measure_mapper_spec.rb | 208 ++++++++++++++++++ 6 files changed, 366 insertions(+), 15 deletions(-) create mode 100644 lib/BOSS/scenario_measure_mapper.rb create mode 100644 spec/tests/unit/scenario_measure_mapper_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 1e540f6..9f58992 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -189,6 +189,7 @@ GEM PLATFORMS arm64-darwin-24 ruby + x64-mingw-ucrt DEPENDENCIES BOSS! diff --git a/docs/scenario_workflow_translation_plan.md b/docs/scenario_workflow_translation_plan.md index 2aa88fc..cbbf54e 100644 --- a/docs/scenario_workflow_translation_plan.md +++ b/docs/scenario_workflow_translation_plan.md @@ -1,6 +1,6 @@ # Scenario Workflow Translation Plan -This plan tracks the work needed for BOSS to translate BuildingSync package scenarios into OpenStudio workflows. PRs 1-4 are covered by the current PR. Future PRs should stay small and follow the roadmap below. +This plan tracks the work needed for BOSS to translate BuildingSync package scenarios into OpenStudio workflows. Roadmap PRs land on the `scenario-workflow` integration branch. Merge `scenario-workflow` into `develop` once the feature is usable end to end (after PR 15). Future PRs should stay small and follow the roadmap below. ## Goal @@ -150,7 +150,7 @@ Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a ### PR 1: Guiding Plan Doc - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #5, merged into `scenario-workflow` on 2026-10-23 - Scope: Add this plan and set the first-release boundaries. - Done when: The plan explains PR order, acceptance gates, mapping ownership, warnings, scenario selection, and result-writeback scope. - Avoid: Leaving scope or ownership ambiguous. @@ -158,7 +158,7 @@ Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a ### PR 2: Scenario Data Model And Discovery - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #7, merged into `scenario-workflow` via PR #5 - Scope: Add reader-returned structures for first-facility report-level package scenarios. - Done when: Unit tests show `building_151.xml` discovers baseline plus package scenarios and existing reader behavior remains unchanged. - Avoid: Hardcoded namespaces, wrong scenario paths, or reader regressions. @@ -166,7 +166,7 @@ Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a ### PR 3: Measure Index - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #8, merged into `scenario-workflow` via PR #5 - Scope: Index first-facility measures by ID and extract category, name, linked premises, cost/savings, and implementation metadata. - Done when: Tests resolve package `MeasureID` references in `building_151.xml` to parsed measure metadata. - Avoid: Silently dropping unresolved refs or crashing on measures without `TechnologyCategories`. @@ -174,7 +174,7 @@ Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a ### PR 4: Parser Warning Contract - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #9, merged into `scenario-workflow` via PR #5 - Scope: Add structured parser warnings for missing IDs, unresolved refs, empty packages, missing names, missing categories, and packages with no usable measures. - Done when: Tests cover warning cases using `BuildingEQ-1.0.0.xml`, `Golden Test File.xml`, and no-measure fixtures. - Avoid: Warnings that only print to stdout or malformed package data that aborts all discovery. @@ -182,15 +182,15 @@ Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a ### PR 5: Initial Mapping JSON - Status: Done -- Delivered by: PR #10 branch +- Delivered by: PR #10, merged into `scenario-workflow` via PR #5 - Scope: Add `lib/BOSS/scenario_measure_map.json` with a small verified mapping set from `building_151.xml`. - Done when: The JSON is valid and each entry has a source category/name, target `measure_dir_name`, and arguments. - Avoid: Copying legacy mappings without checking current measure directories and arguments. ### PR 6: Basic `ScenarioMeasureMapper` -- Status: Planned -- Delivered by: - +- Status: Done +- Delivered by: `scenario-workflow-6-basic-measure-mapper` branch - Scope: Load the JSON, normalize lookup keys, and map one parsed BuildingSync measure to OpenStudio step specs by `SystemCategoryAffected` plus `MeasureName`. - Done when: Unit tests return expected steps and structured unmapped warnings. - Avoid: Mutating reader data, raising on unmapped measures, or hardcoding mapping rules outside JSON. @@ -240,7 +240,7 @@ Use this roadmap as the tracker. Keep `Status` and `Delivered by` current when a - Status: Planned - Delivered by: - - Scope: Deep-copy baseline OSW and append mapped retrofit steps in deterministic order. Make sure `buildingsync-measures-gem` measures are on the OSW `measure_paths`; `develop` already provides `config/external_measure_repos.yml` for this. -- Done when: OSW tests assert baseline steps plus expected mapped measure steps and arguments, and every mapped `measure_dir_name` resolves from the OSW `measure_paths`. +- Done when: OSW tests assert baseline steps plus expected mapped measure steps and arguments, and every mapped `measure_dir_name` resolves from the OSW `measure_paths`. A test covers the LED mapping, whose `SetLightingLoadsByLPD` step also exists in the baseline, and proves the baseline step's arguments are unchanged. - Avoid: Mutating the baseline OSW, omitting baseline creation steps, or using nondeterministic step order. ### PR 13: Generic OSW Step Helpers @@ -317,7 +317,7 @@ Use this section for stable facts future PRs need. Keep entries short and tied t ### PR 2: Scenario Data Model And Discovery - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #7, merged into `scenario-workflow` via PR #5 - Handoff: `BOSS::BuildingSyncReader#get_report_scenarios` returns first-facility report scenarios as symbol-keyed hashes with `scenario_id`, `scenario_name`, `temporal_status`, `report_id`, `scenario_type`, `package_id`, `reference_case_id`, `measure_ids`, and `linked_premises_idrefs`. - Handoff: `BOSS::BuildingSyncReader#get_package_measure_scenarios` filters those records to package-of-measures scenarios. PR 3 should use `measure_ids` to resolve package references against facility measures. - Verification: `bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb` - 22 examples, 0 failures. @@ -327,7 +327,7 @@ Use this section for stable facts future PRs need. Keep entries short and tied t ### PR 3: Measure Index - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #8, merged into `scenario-workflow` via PR #5 - Handoff: `BOSS::BuildingSyncReader#get_measures` returns first-facility measures as a hash keyed by `Measure/@ID`. Values include `measure_id`, `system_category_affected`, `technology_category_element_name`, `measure_name`, `custom_measure_name`, `linked_premises_idrefs`, useful cost/savings fields, and `implementation_status`. - Handoff: Missing optional fields return `nil` or `[]`. Missing `Measures` returns `{}`. Measures without `TechnologyCategories` remain parseable. - Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb` - 29 examples, 0 failures. @@ -337,7 +337,7 @@ Use this section for stable facts future PRs need. Keep entries short and tied t ### PR 4: Parser Warning Contract - Status: Done -- Delivered by: Current PR #5 branch +- Delivered by: PR #9, merged into `scenario-workflow` via PR #5 - Handoff: `BOSS::BuildingSyncReader#get_parser_warnings` returns symbol-keyed warning hashes with `code`, `severity`, `message`, and relevant scenario/package/measure context. Existing scenario and measure reader return shapes remain unchanged. - Handoff: Warning codes cover missing scenario/package/measure IDs, missing `MeasureID` references, unresolved measure references, packages without `MeasureID` references, packages with no parser-usable measures, and resolved measures missing `SystemCategoryAffected`, technology category, or `MeasureName`. - Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb` - 33 examples, 0 failures. @@ -347,7 +347,7 @@ Use this section for stable facts future PRs need. Keep entries short and tied t ### PR 5: Initial Mapping JSON - Status: Done -- Delivered by: PR #10 branch +- Delivered by: PR #10, merged into `scenario-workflow` via PR #5 - Handoff: `lib/BOSS/scenario_measure_map.json` maps 12 `building_151.xml` category/name pairs to OSW-shaped steps with `measure_dir_name` and an argument hash that includes `__SKIP__`. Source strings match reader output exactly. - Handoff: Roof and wall absolute insulation targets use `buildingsync-measures-gem` `modify_envelope_insulation` with SI R-values (R-30 IP = 5.28, R-25 IP = 4.40 m²K/W). Other mappings use `openstudio-ee` or `openstudio-common-measures`. Relative mappings such as air sealing, ceiling insulation by percentage, and thermal bypasses stay on relative measures. - Deferred: Daylight controls, package unit replacement, burner replacement, and boiler replacement. The `buildingsync-measures-gem` split HVAC measures (`modify_existing_hvac_equipment_efficiencies`, `modify_existing_plant_equipment`, `hvac_packaged_rooftop_*`) need exact model object or zone names. Map them in PR 8 with context rules after `buildingsync-measures-gem` PR #3 merges. @@ -355,6 +355,21 @@ Use this section for stable facts future PRs need. Keep entries short and tied t - Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit/scenario_measure_map_spec.rb`. - Follow-up: PR 6 adds `BOSS::ScenarioMeasureMapper`. PR 12 must put `buildingsync-measures-gem` on OSW `measure_paths`. +### PR 6: Basic `ScenarioMeasureMapper` + +- Status: Done +- Delivered by: `scenario-workflow-6-basic-measure-mapper` branch +- Handoff: `BOSS::ScenarioMeasureMapper.new(map_path = SCENARIO_MEASURE_MAP_PATH)` loads the JSON once with symbol keys. `SCENARIO_MEASURE_MAP_PATH` lives in `lib/BOSS/constants.rb`. A missing file, invalid JSON, or a missing `mappings` array raises `ArgumentError`. +- Handoff: `#map_measure(measure)` takes one value from `BuildingSyncReader#get_measures` and returns `{ measure_id:, steps:, warnings: }`. Each step is a deep copy shaped `{ measure_dir_name:, arguments: { __SKIP__: false, ... } }`, in JSON order. Callers may mutate returned steps safely. +- Handoff: Lookup key is `[system_category_affected, measure_name]`, normalized with strip, squeeze spaces, and downcase. On duplicate JSON keys the first entry wins. +- Handoff: Unmapped measures never raise. They return `steps: []` and one warning shaped like reader warnings: `code`, `severity: :warning`, `message`, `measure_id`, `system_category_affected`, `measure_name`. Codes are `:measure_lookup_key_incomplete` (blank category or name) and `:unmapped_measure` (no JSON entry). Each warning is also logged via `BOSS.BOSS_logger.warn`. +- Handoff: On `building_151.xml`, 12 of 16 measures map. Measure4, Measure5, Measure6, and Measure18 return `:unmapped_measure`, matching the PR 5 deferrals. +- Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit/scenario_measure_mapper_spec.rb` - 11 examples, 0 failures. +- Verification: `C:\Ruby32-x64\bin\ruby.exe -S bundle exec rspec spec/tests/unit` - 78 examples, 0 failures, no constant redefinition warnings. +- Verification: `bundle exec rubocop` on the changed files reports only `Layout/EndOfLine` on Windows working copies; git normalizes line endings on commit. +- Follow-up: PR 7 should handle blank `system_category_affected` by falling back to `technology_category_element_name`, which narrows `:measure_lookup_key_incomplete` to blank `measure_name` only. +- Follow-up for PR 12/13: `OpenStudio::Extension.set_measure_argument` matches every step with the same `measure_dir_name` unless `step_name` is passed. `SetLightingLoadsByLPD` is both a baseline step and the LED mapping target. Append mapped steps with their arguments already set, or pass a unique `step_name`, so baseline step arguments are not overwritten. + ## Verification Commands Use the narrowest command that proves the current PR. Broaden only when the change justifies it. diff --git a/lib/BOSS/constants.rb b/lib/BOSS/constants.rb index a7a4e81..b7929cd 100644 --- a/lib/BOSS/constants.rb +++ b/lib/BOSS/constants.rb @@ -12,6 +12,7 @@ SCHEMA_2_7_0_URL = 'https://raw.githubusercontent.com/BuildingSync/schema/v2.7.0/BuildingSync.xsd' EMPTY_BASELINE_OSW_PATH = File.expand_path(File.join(__dir__, 'empty_baseline.osw')) BUILDING_TYPES_BY_OCCUPANCY_CLASSIFICATION_PATH = File.expand_path(File.join(__dir__, 'buildingsync_reader/building_types_by_occupancy_classification.json')) +SCENARIO_MEASURE_MAP_PATH = File.expand_path(File.join(__dir__, 'scenario_measure_map.json')) LOCAL_MEASURES_DIR = File.expand_path(File.join(__dir__, '..', 'measures')) WEATHER_DIR = File.expand_path(File.join(__dir__, '../../weather')) EXTERNAL_MEASURE_REPOS_MANIFEST_PATH = File.expand_path(File.join(__dir__, '..', '..', 'config', 'external_measure_repos.yml')) diff --git a/lib/BOSS/scenario_measure_mapper.rb b/lib/BOSS/scenario_measure_mapper.rb new file mode 100644 index 0000000..bc394e0 --- /dev/null +++ b/lib/BOSS/scenario_measure_mapper.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# ******************************************************************************* +# OpenStudio(R), Copyright (c) Alliance for Energy Innovation, LLC. +# BuildingSync(R), Copyright (c) Alliance for Energy Innovation, LLC. +# See also https://github.com/BuildingSync/BOSS/blob/develop/LICENSE.md +# ******************************************************************************* + +require 'json' +require 'BOSS/constants' +require 'BOSS/boss_logger' + +module BOSS + # Maps one parsed BuildingSync measure (a value from BuildingSyncReader#get_measures) + # to OpenStudio OSW step specs using scenario_measure_map.json. + class ScenarioMeasureMapper + def initialize(map_path = SCENARIO_MEASURE_MAP_PATH) + @map_path = map_path + @mappings_by_key = _index_mappings(_load_mappings(map_path)) + end + + # @param measure [Hash] symbol-keyed measure hash from the reader + # @return [Hash] { measure_id:, steps: [ { measure_dir_name:, arguments: {} } ], warnings: [] } + def map_measure(measure) + system_category_affected = measure[:system_category_affected] + measure_name = measure[:measure_name] + key = _lookup_key(system_category_affected, measure_name) + + if key.nil? + return _unmapped_result( + measure, + :measure_lookup_key_incomplete, + 'Measure is missing SystemCategoryAffected or MeasureName needed for mapping lookup.' + ) + end + + mapping = @mappings_by_key[key] + if mapping.nil? + return _unmapped_result(measure, :unmapped_measure, 'Measure has no OpenStudio mapping.') + end + + return { + measure_id: measure[:measure_id], + steps: _deep_copy(mapping[:steps]), + warnings: [] + } + end + + private + + def _load_mappings(map_path) + raise ArgumentError, "Scenario measure map '#{map_path}' does not exist" if !File.file?(map_path.to_s) + + begin + map = JSON.parse(File.read(map_path), symbolize_names: true) + rescue JSON::ParserError => e + raise ArgumentError, "Scenario measure map '#{map_path}' is not valid JSON: #{e.message}" + end + + mappings = map.is_a?(Hash) ? map[:mappings] : nil + raise ArgumentError, "Scenario measure map '#{map_path}' must define a 'mappings' array" if !mappings.is_a?(Array) + + return mappings + end + + def _index_mappings(mappings) + mappings_by_key = {} + mappings.each do |mapping| + key = _lookup_key(mapping[:system_category_affected], mapping[:measure_name]) + next if key.nil? || mappings_by_key.key?(key) + + mappings_by_key[key] = mapping + end + + return mappings_by_key.freeze + end + + def _lookup_key(system_category_affected, measure_name) + system_category_key = _normalize(system_category_affected) + measure_name_key = _normalize(measure_name) + return nil if system_category_key.nil? || measure_name_key.nil? + + return [system_category_key, measure_name_key] + end + + def _normalize(value) + normalized = value.to_s.strip.squeeze(' ').downcase + return normalized.empty? ? nil : normalized + end + + def _unmapped_result(measure, code, message) + warning = { + code:, + severity: :warning, + message:, + measure_id: measure[:measure_id], + system_category_affected: measure[:system_category_affected], + measure_name: measure[:measure_name] + } + BOSS.BOSS_logger.warn( + "BOSS.ScenarioMeasureMapper: #{message} " \ + "(measure_id: #{warning[:measure_id].inspect}, " \ + "system_category_affected: #{warning[:system_category_affected].inspect}, " \ + "measure_name: #{warning[:measure_name].inspect})" + ) + + return { + measure_id: measure[:measure_id], + steps: [], + warnings: [warning] + } + end + + def _deep_copy(value) + return Marshal.load(Marshal.dump(value)) + end + end +end diff --git a/spec/tests/unit/scenario_measure_map_spec.rb b/spec/tests/unit/scenario_measure_map_spec.rb index 187842d..8413d5f 100644 --- a/spec/tests/unit/scenario_measure_map_spec.rb +++ b/spec/tests/unit/scenario_measure_map_spec.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true require 'json' - -SCENARIO_MEASURE_MAP_PATH = File.expand_path('../../../lib/BOSS/scenario_measure_map.json', __dir__) +require 'BOSS/constants' RSpec.describe 'scenario_measure_map.json' do let(:scenario_measure_map) { JSON.parse(File.read(SCENARIO_MEASURE_MAP_PATH)) } @@ -33,6 +32,15 @@ end end + it 'has unique normalized category and measure name lookup keys' do + normalize = ->(value) { value.to_s.strip.squeeze(' ').downcase } + keys = mappings.map do |mapping| + [normalize.call(mapping.fetch('system_category_affected')), normalize.call(mapping.fetch('measure_name'))] + end + + expect(keys.uniq.length).to eq keys.length + end + it 'covers the verified initial building_151 measure set' do expected_sources = [ ['Air Distribution', 'Add or repair economizer'], diff --git a/spec/tests/unit/scenario_measure_mapper_spec.rb b/spec/tests/unit/scenario_measure_mapper_spec.rb new file mode 100644 index 0000000..b26c57d --- /dev/null +++ b/spec/tests/unit/scenario_measure_mapper_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +# ******************************************************************************* +# OpenStudio(R), Copyright (c) Alliance for Energy Innovation, LLC. +# BuildingSync(R), Copyright (c) Alliance for Energy Innovation, LLC. +# See also https://github.com/BuildingSync/BOSS/blob/develop/LICENSE.md +# ******************************************************************************* + +require 'json' +require 'tempfile' +require 'BOSS/constants' +require 'BOSS/buildingsync_reader/buildingsync_reader' +require 'BOSS/scenario_measure_mapper' + +RSpec.describe 'ScenarioMeasureMapper' do + def mapper_fixture_path(*parts) + File.expand_path(File.join('../../files', *parts), __dir__) + end + + def building_151_measures + xml_path = mapper_fixture_path('v2.7.0', 'building_151.xml') + doc = REXML::Document.new(File.read(xml_path), ignore_whitespace_nodes: :all) + BOSS::BuildingSyncReader.new(doc, nil, ASHRAE90_1).get_measures + end + + def with_map_file(content) + file = Tempfile.new(['scenario_measure_map', '.json']) + file.write(content) + file.close + yield file.path + ensure + file&.unlink + end + + def lighting_measure(overrides = {}) + { + measure_id: 'Measure1', + system_category_affected: 'Lighting', + technology_category_element_name: 'LightingImprovements', + measure_name: 'Retrofit with light emitting diode technologies' + }.merge(overrides) + end + + # Silence mapper warning logs; the structured return value is what these tests check. + before { allow(BOSS.BOSS_logger).to receive(:warn) } + + let(:mapper) { BOSS::ScenarioMeasureMapper.new } + + describe 'map_measure should' do + it 'map building_151 Measure1 to the LED lighting step' do + # Set Up + measure = building_151_measures.fetch('Measure1') + expected = JSON.parse( + File.read(mapper_fixture_path('v2.7.0', 'expected', 'building_151_scenario1_resolved_measures.json')), + symbolize_names: true + ) + expect(measure).to eq expected[:resolved_measures].first + + # Action + result = mapper.map_measure(measure) + + # Assert + expect(result).to eq( + measure_id: 'Measure1', + steps: [ + { measure_dir_name: 'SetLightingLoadsByLPD', arguments: { __SKIP__: false, lpd: 0.6 } } + ], + warnings: [] + ) + end + + it 'map the verified building_151 measures and warn for the deferred ones' do + # Set Up + measures = building_151_measures + + # Action + results = measures.transform_values { |measure| mapper.map_measure(measure) } + + # Assert + expect(measures.length).to eq 16 + mapped_ids = results.select { |_id, result| result[:steps].any? }.keys + unmapped_ids = results.select { |_id, result| result[:steps].empty? }.keys + expect(mapped_ids.length).to eq 12 + expect(unmapped_ids).to contain_exactly('Measure4', 'Measure5', 'Measure6', 'Measure18') + + results.each_value do |result| + if result[:steps].any? + expect(result[:warnings]).to eq [] + else + expect(result[:warnings].map { |warning| warning[:code] }).to eq [:unmapped_measure] + end + end + + boiler_warning = results.fetch('Measure18')[:warnings].first + expect(boiler_warning).to eq( + code: :unmapped_measure, + severity: :warning, + message: 'Measure has no OpenStudio mapping.', + measure_id: 'Measure18', + system_category_affected: 'Heating System', + measure_name: 'Replace boiler' + ) + end + + it 'normalize case and whitespace in lookup keys' do + # Set Up + messy_measure = lighting_measure( + system_category_affected: ' LIGHTING ', + measure_name: 'retrofit with light Emitting diode technologies ' + ) + + # Action + result = mapper.map_measure(messy_measure) + + # Assert + expect(result[:steps]).to eq mapper.map_measure(lighting_measure)[:steps] + expect(result[:warnings]).to eq [] + end + + it 'warn without raising when MeasureName is missing' do + # Action + result = mapper.map_measure(lighting_measure(measure_name: nil)) + + # Assert + expect(result[:steps]).to eq [] + expect(result[:warnings].map { |warning| warning[:code] }).to eq [:measure_lookup_key_incomplete] + expect(result[:warnings].first).to include(measure_id: 'Measure1', severity: :warning, measure_name: nil) + end + + it 'warn without raising when SystemCategoryAffected is missing' do + # Action + result = mapper.map_measure(lighting_measure(system_category_affected: ' ')) + + # Assert + expect(result[:steps]).to eq [] + expect(result[:warnings].map { |warning| warning[:code] }).to eq [:measure_lookup_key_incomplete] + end + + it 'log a warning for unmapped measures' do + # Action + mapper.map_measure(lighting_measure(measure_name: 'Not a mapped measure')) + + # Assert + expect(BOSS.BOSS_logger).to have_received(:warn).with(/Measure has no OpenStudio mapping/).once + end + + it 'not mutate the input measure or the loaded mapping' do + # Set Up + measure = lighting_measure + measure_before = Marshal.load(Marshal.dump(measure)) + + # Action + first_result = mapper.map_measure(measure) + first_result[:steps].first[:arguments][:lpd] = 99.0 + first_result[:steps] << { measure_dir_name: 'Extra', arguments: {} } + second_result = mapper.map_measure(measure) + + # Assert + expect(measure).to eq measure_before + expect(second_result[:steps]).to eq [ + { measure_dir_name: 'SetLightingLoadsByLPD', arguments: { __SKIP__: false, lpd: 0.6 } } + ] + end + + it 'preserve step order for multi-step mappings' do + # Set Up + map_json = JSON.generate( + mappings: [ + { + system_category_affected: 'Lighting', + measure_name: 'Two step measure', + steps: [ + { measure_dir_name: 'SecondAlphabetically', arguments: { __SKIP__: false } }, + { measure_dir_name: 'AFirstAlphabetically', arguments: { __SKIP__: false } } + ] + } + ] + ) + + with_map_file(map_json) do |map_path| + # Action + result = BOSS::ScenarioMeasureMapper.new(map_path).map_measure(lighting_measure(measure_name: 'Two step measure')) + + # Assert + expect(result[:steps].map { |step| step[:measure_dir_name] }).to eq ['SecondAlphabetically', 'AFirstAlphabetically'] + end + end + end + + describe 'initialize should' do + it 'raise ArgumentError when the map file does not exist' do + expect { BOSS::ScenarioMeasureMapper.new(mapper_fixture_path('missing_map.json')) } + .to raise_error(ArgumentError, /does not exist/) + end + + it 'raise ArgumentError when the map file is not valid JSON' do + with_map_file('{ not json') do |map_path| + expect { BOSS::ScenarioMeasureMapper.new(map_path) }.to raise_error(ArgumentError, /not valid JSON/) + end + end + + it 'raise ArgumentError when the map file has no mappings array' do + with_map_file(JSON.generate(schema_version: '1.0')) do |map_path| + expect { BOSS::ScenarioMeasureMapper.new(map_path) }.to raise_error(ArgumentError, /'mappings' array/) + end + end + end +end