diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41b2b86..2211171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,5 @@ jobs: gem install bundler -v 2.4.10 bundle config set --local path "$GITHUB_WORKSPACE/.bundle/install" bundle install - - name: Run Integration Tests - run: bundle exec rspec spec/tests/integration/write_and_run_osws_spec.rb - if: always() - - name: Run Unit Tests - run: bundle exec rspec spec/tests/unit/buildingsync_reader_spec.rb - if: always() + - name: Run Tests + run: bundle exec rspec diff --git a/Gemfile.lock b/Gemfile.lock index 1644caf..1e540f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -158,6 +158,11 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.30.0) parser (>= 3.2.1.0) + rubocop-checkstyle_formatter (0.6.0) + rubocop (>= 1.14.0) + rubocop-performance (1.20.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) ruby-ole (1.2.13.1) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) @@ -191,6 +196,8 @@ DEPENDENCIES rake (~> 13.0) rspec (~> 3.13) rubocop (= 1.50) + rubocop-checkstyle_formatter (= 0.6.0) + rubocop-performance (= 1.20.0) BUNDLED WITH 2.4.10 diff --git a/README.md b/README.md index cfe7b34..55d94bb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,30 @@ BuildingSync OpenStudio Simulator (BOSS) takes in BuildingSync files, creates Op bundle install ``` +4. Install external non-gem measure repositories declared in the shipped manifest. + + ```bash + bundle exec rake measures:install_external + ``` + +5. (Optional) List resolved external measure roots. + + ```bash + bundle exec rake measures:list_external + ``` + +The shipped manifest lives at [config/external_measure_repos.yml](config/external_measure_repos.yml) and is preconfigured with: + +- local measure roots: `lib/measures` (first in precedence) +- external repository: comstock (`measures` and `resources/measures`) + +Measure path precedence is: + +1. local measure roots from [config/external_measure_repos.yml](config/external_measure_repos.yml) +2. gem-provided measure directories +3. external non-gem repository measure roots from [config/external_measure_repos.yml](config/external_measure_repos.yml) + +The generated `in.osw` will include these paths in order under `measure_paths`. ## OpenStudio Compatibility Version @@ -97,6 +121,11 @@ bundle exec boss write_baseline_osw BUILDINGSYNC_FILE -o OUTPUT_PATH [options] bundle exec boss run_osw BUILDINGSYNC_FILE -o OUTPUT_PATH [options] ``` +To get help text, type the following: +```bash +bundle exec boss help [COMMAND] +``` + ### Options - `-o`, `--output_path` (required): directory where BOSS writes output (for example, use an XML-specific folder such as `output/v2.7.0/L100_Audit-1.0.0`) diff --git a/Rakefile b/Rakefile index 2813932..23985fe 100644 --- a/Rakefile +++ b/Rakefile @@ -6,10 +6,37 @@ require 'bundler/gem_tasks' require 'rspec/core/rake_task' +require_relative './lib/BOSS/constants' +require_relative './lib/BOSS/external_measure_repo_manager' RSpec::Core::RakeTask.new(:spec) require 'rubocop/rake_task' RuboCop::RakeTask.new -task default: :spec \ No newline at end of file +task default: :spec + +namespace :measures do + desc 'Clone/fetch external non-gem measure repositories declared in config/external_measure_repos.yml' + task :install_external do + manager = BOSS::ExternalMeasureRepoManager.new + + if !manager.manifest_exists? + puts "No external measure manifest found at #{EXTERNAL_MEASURE_REPOS_MANIFEST_PATH}" + next + end + + dirs = manager.install_all + puts "Installed external measure roots (#{dirs.length}):" + dirs.each { |dir| puts " - #{dir}" } + end + + desc 'List resolved external non-gem measure directories' + task :list_external do + manager = BOSS::ExternalMeasureRepoManager.new + dirs = manager.resolved_measure_directories + + puts "External measure roots (#{dirs.length}):" + dirs.each { |dir| puts " - #{dir}" } + end +end \ No newline at end of file diff --git a/config/external_measure_repos.yml b/config/external_measure_repos.yml new file mode 100644 index 0000000..212bbcd --- /dev/null +++ b/config/external_measure_repos.yml @@ -0,0 +1,27 @@ +# External non-gem measure repositories. +# +# Precedence in workflow measure lookup is: +# 1) local_measure_roots below +# 2) gem-provided measure dirs +# 3) external repos listed here +# +# local_measure_roots are resolved relative to this file. +# The default shipped configuration keeps this repository's local measures first. +local_measure_roots: + - ../lib/measures + +# For each repo: +# - name: local checkout folder name under vendor/external_measures +# - url: git repository URL +# - ref: pinned tag/branch/commit to checkout +# - measure_roots: one or more paths inside the repo that contain measure directories +# + +# The below measures are needed for the BOSS L200 workflow. +repos: + - name: comstock + url: https://github.com/NatLabRockies/comstock.git + ref: main + measure_roots: + - measures + - resources/measures diff --git a/lib/BOSS/boss.rb b/lib/BOSS/boss.rb index ab17f31..71bbe49 100644 --- a/lib/BOSS/boss.rb +++ b/lib/BOSS/boss.rb @@ -9,6 +9,7 @@ require 'fileutils' require 'BOSS/buildingsync_reader/buildingsync_reader' require 'BOSS/osw_arg_populator' +require 'BOSS/external_measure_repo_manager' require 'openstudio/common_measures' require 'openstudio/model_articulation' @@ -56,6 +57,31 @@ def write_baseline_osw OSWArgPopulator::populate_set_electric_equipment_loads_by_epd_args(baseline_osw, @bsync_reader) OSWArgPopulator::populate_openstudio_results_args(baseline_osw, @bsync_reader) + # Gather extension-derived paths (typically gem-based measures/files) from ObjectSpace. + OpenStudio::Extension.configure_osw(baseline_osw) + + # Force ordering: local BOSS measures, extension-discovered (gems), then external repos. + manager = ExternalMeasureRepoManager.new + local_measure_dirs = manager.local_measure_directories + external_measure_dirs = manager.resolved_measure_directories + discovered_measure_dirs = baseline_osw[:measure_paths] || [] + + discovered_nonlocal_nonexternal = discovered_measure_dirs.reject do |path| + local_measure_dirs.include?(path) || external_measure_dirs.include?(path) + end + + baseline_osw[:measure_paths] = ordered_unique(local_measure_dirs + discovered_nonlocal_nonexternal + external_measure_dirs) + + local_file_paths = local_measure_dirs.map { |path| measure_path_to_files_path(path) } + external_file_paths = external_measure_dirs.map { |path| measure_path_to_files_path(path) } + discovered_file_paths = baseline_osw[:file_paths] || [] + + discovered_file_nonlocal_nonexternal = discovered_file_paths.reject do |path| + local_file_paths.include?(path) || external_file_paths.include?(path) + end + + baseline_osw[:file_paths] = ordered_unique(local_file_paths + discovered_file_nonlocal_nonexternal + external_file_paths) + # write to file workflow_dir = File.join(@output_dir, 'baseline') FileUtils.mkdir_p(workflow_dir) @@ -85,5 +111,23 @@ def run_baseline_osw # run the baseline osm return runner.run_osws([baseline_osw_path]) end + + private + + def measure_path_to_files_path(measure_path) + path = measure_path.to_s + replaced = path.sub(%r{/measures/?$}, '/files') + return replaced if replaced != path + + File.expand_path(File.join(path, '..', 'files')) + end + + def ordered_unique(paths) + (paths || []).each_with_object([]) do |path, acc| + next if path.nil? || path.empty? || acc.include?(path) + + acc << path + end + end end end diff --git a/lib/BOSS/constants.rb b/lib/BOSS/constants.rb index 587ca9e..a7a4e81 100644 --- a/lib/BOSS/constants.rb +++ b/lib/BOSS/constants.rb @@ -12,9 +12,10 @@ 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')) - +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')) +EXTERNAL_MEASURE_REPOS_INSTALL_DIR = File.expand_path(File.join(__dir__, '..', '..', 'vendor', 'external_measures')) # Standards strings ASHRAE90_1 = 'ASHRAE90.1' diff --git a/lib/BOSS/external_measure_repo_manager.rb b/lib/BOSS/external_measure_repo_manager.rb new file mode 100644 index 0000000..c4aa967 --- /dev/null +++ b/lib/BOSS/external_measure_repo_manager.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +# ******************************************************************************* +# OpenStudio(R), Copyright (c) Alliance for Energy Innovation, LLC. +# See also https://github.com/BuildingSync/BuildingSync-gem/blob/develop/LICENSE.md +# ******************************************************************************* + +require 'fileutils' +require 'open3' +require 'yaml' + +module BOSS + # Manages non-gem measure repositories declared in a manifest file. + class ExternalMeasureRepoManager + def initialize(manifest_path: EXTERNAL_MEASURE_REPOS_MANIFEST_PATH, install_dir: EXTERNAL_MEASURE_REPOS_INSTALL_DIR) + @manifest_path = manifest_path + @install_dir = install_dir + end + + def manifest_exists? + File.file?(@manifest_path) + end + + def configured? + !repo_entries.empty? + end + + def local_measure_directories + return [LOCAL_MEASURES_DIR] if !manifest_exists? + + roots = manifest['local_measure_roots'] + roots = [LOCAL_MEASURES_DIR] if !roots.is_a?(Array) || roots.empty? + + directories = roots.map do |path| + File.expand_path(path, File.expand_path('..', @manifest_path)) + end + + directories.select { |path| Dir.exist?(path) }.uniq + end + + def install_all + return [] if !manifest_exists? + + FileUtils.mkdir_p(@install_dir) + + repo_entries.each do |repo_entry| + install_repo(repo_entry) + end + + resolved_measure_directories + end + + def resolved_measure_directories + return [] if !manifest_exists? + + directories = [] + repo_entries.each do |repo_entry| + repo_root = repo_checkout_dir(repo_entry) + if !Dir.exist?(repo_root) + message = "Repository '#{repo_entry['name']}' is not installed at #{repo_root}. Run rake measures:install_external." + if defined?(OpenStudio) && OpenStudio.respond_to?(:logFree) + OpenStudio.logFree(OpenStudio::Warn, 'BOSS.ExternalMeasureRepoManager.resolved_measure_directories', message) + else + warn("BOSS.ExternalMeasureRepoManager.resolved_measure_directories: #{message}") + end + next + end + + repo_entry['measure_roots'].each do |root_rel_path| + root_abs_path = File.expand_path(File.join(repo_root, root_rel_path)) + if Dir.exist?(root_abs_path) + directories << root_abs_path + else + message = "Configured measure root '#{root_rel_path}' does not exist for repository '#{repo_entry['name']}' (#{root_abs_path})." + if defined?(OpenStudio) && OpenStudio.respond_to?(:logFree) + OpenStudio.logFree(OpenStudio::Warn, 'BOSS.ExternalMeasureRepoManager.resolved_measure_directories', message) + else + warn("BOSS.ExternalMeasureRepoManager.resolved_measure_directories: #{message}") + end + end + end + end + + ordered_unique(directories) + end + + private + + def manifest + @manifest ||= begin + parsed = YAML.safe_load(File.read(@manifest_path), permitted_classes: [], aliases: false) + parsed.is_a?(Hash) ? parsed : {} + end + end + + def repo_entries + return [] if !manifest_exists? + + repos = manifest.is_a?(Hash) ? manifest['repos'] : nil + return [] if repos.nil? + raise StandardError, "Expected 'repos' array in #{@manifest_path}" if !repos.is_a?(Array) + + repos.map do |repo| + validate_repo_entry(repo) + end + end + + def validate_repo_entry(repo) + if !repo.is_a?(Hash) + raise StandardError, "Each repo entry in #{@manifest_path} must be a map" + end + + required = %w[name url ref measure_roots] + missing = required.select { |key| repo[key].nil? || repo[key].to_s.empty? } + if !missing.empty? + raise StandardError, "Repo entry is missing required keys #{missing.join(', ')} in #{@manifest_path}" + end + + # Prevent path traversal / unexpected nesting in checkout dir. + if !repo['name'].to_s.match?(/\A[\w.-]+\z/) + raise StandardError, "Repo name '#{repo['name']}' must match /\\A[\\w.-]+\\z/ in #{@manifest_path}" + end + + if !repo['measure_roots'].is_a?(Array) || repo['measure_roots'].empty? + raise StandardError, "Repo '#{repo['name']}' must define a non-empty measure_roots array" + end + repo + end + + def install_repo(repo_entry) + repo_dir = repo_checkout_dir(repo_entry) + measure_roots = repo_entry['measure_roots'] + + if Dir.exist?(File.join(repo_dir, '.git')) + run_git(%W[-C #{repo_dir} remote set-url origin #{repo_entry['url']}]) + else + FileUtils.mkdir_p(File.dirname(repo_dir)) + run_git(%W[clone --filter=blob:none --no-checkout #{repo_entry['url']} #{repo_dir}]) + end + + run_git(%W[-C #{repo_dir} sparse-checkout init --cone]) + run_git(['-C', repo_dir, 'sparse-checkout', 'set', *measure_roots]) + run_git(%W[-C #{repo_dir} fetch --depth 1 origin #{repo_entry['ref']}]) + run_git(%W[-C #{repo_dir} checkout --force FETCH_HEAD]) + end + + def repo_checkout_dir(repo_entry) + File.join(@install_dir, repo_entry['name']) + end + + def run_git(args) + stdout, stderr, status = Open3.capture3('git', *args) + if !status.success? + raise StandardError, "git #{args.join(' ')} failed:\n#{stdout}\n#{stderr}" + end + end + + def ordered_unique(paths) + seen = {} + paths.each_with_object([]) do |path, acc| + next if seen[path] + + seen[path] = true + acc << path + end + end + end +end diff --git a/lib/boss_cli.rb b/lib/boss_cli.rb index 1e22dcd..8fd6c4d 100755 --- a/lib/boss_cli.rb +++ b/lib/boss_cli.rb @@ -10,7 +10,8 @@ module BOSS class CLI < Thor - desc "write_baseline_osw BUILDINGSYNC_FILE", "writes a baseline osw using data from the provided Buildingsync file." + desc "write_baseline_osw BUILDINGSYNC_FILE", "Writes a baseline osw using data from the provided Buildingsync file." + long_desc "Example: $ boss write_baseline_osw my_buildingsync.xml -o ./output_dir -w ./specific_weather_file.epw" option :output_path, :required => true, :aliases => "-o", :desc => "path to where the ows gets written. Defaults to '.'" option :epw_path, :aliases => "-w", :desc => "path to weather file. If not given, one is derived from the BUILDINGSYNC_FILE." option :standard_version, :aliases => "-v", :desc => "building standard to use (eg ASHRAE90.1 or CaliforniaTitle24). Defaults to ASHRAE90.1" @@ -26,13 +27,15 @@ def write_baseline_osw(buildingsync_file) end end - desc "run_osw BUILDINGSYNC_FILE", "runs an existing baseline osw from the output path." - option :output_path, :required => true, :aliases => "-o", :desc => "path where the baseline osw exists (expects baseline/in.osw)." + desc "run_osw BUILDINGSYNC_FILE", "Runs an existing baseline osw from the output path." + long_desc "Example: $ boss run_osw my_buildingsync.xml -o ./output_dir -w ./specific_weather_file.epw" + option :output_path, :required => true, :aliases => "-o", :desc => "path where the baseline osw exists (baseline/in.osw will be appended to the path)." option :epw_path, :aliases => "-w", :desc => "path to weather file. If not given, one is derived from the BUILDINGSYNC_FILE." - option :standard_version, :aliases => "-v", :desc => "building standard to use (eg ASHRAE90.1 or CaliforniaTitle24). Defaults to ASHRAE90.1" + # option :standard_version, :aliases => "-v", :desc => "building standard to use (eg ASHRAE90.1 or CaliforniaTitle24). Defaults to ASHRAE90.1" + # todo: we don't really need standard_version at this point, but we need to keep it for now to avoid breaking the CLI interface. We can remove it later. def run_osw(buildingsync_file) - standard_version = options[:standard_version] || ASHRAE90_1 - boss = BOSS::Boss.new(buildingsync_file, options[:output_path], options[:epw_path], standard_version) + #standard_version = options[:standard_version] || ASHRAE90_1 + boss = BOSS::Boss.new(buildingsync_file, options[:output_path], options[:epw_path], nil) boss.run_baseline_osw end end diff --git a/lib/measures/.gitkeep b/lib/measures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/tests/unit/boss_spec.rb b/spec/tests/unit/boss_spec.rb new file mode 100644 index 0000000..d353103 --- /dev/null +++ b/spec/tests/unit/boss_spec.rb @@ -0,0 +1,118 @@ +# ******************************************************************************* +# 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 'tmpdir' +require 'json' +require 'BOSS/constants' +require 'BOSS/boss' + +BOSS_SPEC_FILES_DIR = File.expand_path('../../files', __dir__) + +RSpec.describe BOSS::Boss do + # A minimal real XML that Boss.new can parse without errors. + let(:xml_path) { File.join(BOSS_SPEC_FILES_DIR, 'v2.7.0', 'building_151.xml') } + + # Placeholder paths that represent the three tiers. + let(:gem_measure_dir) { '/fake/gem/measures' } + let(:external_measure_dir) { '/fake/external/measures' } + + before do + # Prevent real weather file downloads triggered by BuildingSyncReader. + allow(BOSS::BCLWeatherFileDownloader) + .to receive(:download_weather_file_from_city_name) + .and_return(openstudio_weather_file_path) + + # Stub all OSWArgPopulator class methods so they are no-ops; we only care + # about measure_paths ordering, not the steps payload. + [ + :populate_set_run_period_args, + :populate_change_building_location_args, + :populate_create_bar_from_building_type_ratios_args, + :populate_create_typical_building_from_model_args, + :populate_set_lighting_loads_by_LPD_args, + :populate_set_electric_equipment_loads_by_epd_args, + :populate_openstudio_results_args + ].each { |m| allow(OSWArgPopulator).to receive(m) } + end + + describe '#write_baseline_osw measure_paths ordering' do + it 'places local measures first, then gem-provided measures, then external repo measures' do + Dir.mktmpdir do |output_dir| + boss = described_class.new(xml_path, output_dir, nil, ASHRAE90_1) + + # Simulate OpenStudio::Extension.configure_osw adding gem-provided paths. + allow(OpenStudio::Extension).to receive(:configure_osw) do |osw| + osw[:measure_paths] = [gem_measure_dir] + end + + # Control what ExternalMeasureRepoManager reports. + fake_manager = instance_double(BOSS::ExternalMeasureRepoManager) + allow(BOSS::ExternalMeasureRepoManager).to receive(:new).and_return(fake_manager) + allow(fake_manager).to receive(:local_measure_directories).and_return([LOCAL_MEASURES_DIR]) + allow(fake_manager).to receive(:resolved_measure_directories).and_return([external_measure_dir]) + + osw_path = boss.write_baseline_osw + + osw = JSON.parse(File.read(osw_path), symbolize_names: true) + measure_paths = osw[:measure_paths] + + local_idx = measure_paths.index(LOCAL_MEASURES_DIR) + gem_idx = measure_paths.index(gem_measure_dir) + external_idx = measure_paths.index(external_measure_dir) + + expect(local_idx).not_to be_nil, "local measures dir should appear in measure_paths" + expect(gem_idx).not_to be_nil, "gem measures dir should appear in measure_paths" + expect(external_idx).not_to be_nil, "external repo measures dir should appear in measure_paths" + + expect(local_idx).to be < gem_idx, "local measures must come before gem measures" + expect(gem_idx).to be < external_idx, "gem measures must come before external repo measures" + end + end + + it 'omits external repo dir when no external repos are configured' do + Dir.mktmpdir do |output_dir| + boss = described_class.new(xml_path, output_dir, nil, ASHRAE90_1) + + allow(OpenStudio::Extension).to receive(:configure_osw) do |osw| + osw[:measure_paths] = [gem_measure_dir] + end + + fake_manager = instance_double(BOSS::ExternalMeasureRepoManager) + allow(BOSS::ExternalMeasureRepoManager).to receive(:new).and_return(fake_manager) + allow(fake_manager).to receive(:local_measure_directories).and_return([LOCAL_MEASURES_DIR]) + allow(fake_manager).to receive(:resolved_measure_directories).and_return([]) + + osw_path = boss.write_baseline_osw + + measure_paths = JSON.parse(File.read(osw_path), symbolize_names: true)[:measure_paths] + expect(measure_paths).to include(LOCAL_MEASURES_DIR) + expect(measure_paths).to include(gem_measure_dir) + expect(measure_paths).not_to include(external_measure_dir) + end + end + + it 'does not duplicate paths that appear in both gem output and local dirs' do + Dir.mktmpdir do |output_dir| + boss = described_class.new(xml_path, output_dir, nil, ASHRAE90_1) + + # Extension reports the local dir again (can happen with some gem setups). + allow(OpenStudio::Extension).to receive(:configure_osw) do |osw| + osw[:measure_paths] = [LOCAL_MEASURES_DIR, gem_measure_dir] + end + + fake_manager = instance_double(BOSS::ExternalMeasureRepoManager) + allow(BOSS::ExternalMeasureRepoManager).to receive(:new).and_return(fake_manager) + allow(fake_manager).to receive(:local_measure_directories).and_return([LOCAL_MEASURES_DIR]) + allow(fake_manager).to receive(:resolved_measure_directories).and_return([external_measure_dir]) + + osw_path = boss.write_baseline_osw + + measure_paths = JSON.parse(File.read(osw_path), symbolize_names: true)[:measure_paths] + expect(measure_paths.count(LOCAL_MEASURES_DIR)).to eq 1 + end + end + end +end diff --git a/spec/tests/unit/external_measure_repo_manager_spec.rb b/spec/tests/unit/external_measure_repo_manager_spec.rb new file mode 100644 index 0000000..035248f --- /dev/null +++ b/spec/tests/unit/external_measure_repo_manager_spec.rb @@ -0,0 +1,312 @@ +# ******************************************************************************* +# 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 'tmpdir' +require 'fileutils' +require 'BOSS/constants' +require 'BOSS/external_measure_repo_manager' + +RSpec.describe BOSS::ExternalMeasureRepoManager do + # Helpers to write a manifest YAML to a temp dir + def write_manifest(dir, content) + path = File.join(dir, 'external_measure_repos.yml') + File.write(path, content) + path + end + + def manager_for(manifest_path, install_dir) + described_class.new(manifest_path: manifest_path, install_dir: install_dir) + end + + # --------------------------------------------------------------------------- + describe '#manifest_exists?' do + it 'returns true when the manifest file is present' do + Dir.mktmpdir do |dir| + path = write_manifest(dir, "repos: []\n") + mgr = manager_for(path, dir) + expect(mgr.manifest_exists?).to be true + end + end + + it 'returns false when the manifest file is absent' do + Dir.mktmpdir do |dir| + mgr = manager_for(File.join(dir, 'missing.yml'), dir) + expect(mgr.manifest_exists?).to be false + end + end + end + + # --------------------------------------------------------------------------- + describe '#configured?' do + it 'returns false when manifest has no repos' do + Dir.mktmpdir do |dir| + path = write_manifest(dir, "repos: []\n") + expect(manager_for(path, dir).configured?).to be false + end + end + + it 'returns false when manifest has no repos key' do + Dir.mktmpdir do |dir| + path = write_manifest(dir, "local_measure_roots:\n - ./measures\n") + expect(manager_for(path, dir).configured?).to be false + end + end + + it 'returns true when at least one repo is declared' do + Dir.mktmpdir do |dir| + yaml = <<~YAML + repos: + - name: my_repo + url: https://example.com/repo.git + ref: main + measure_roots: + - measures + YAML + path = write_manifest(dir, yaml) + expect(manager_for(path, dir).configured?).to be true + end + end + end + + # --------------------------------------------------------------------------- + describe '#local_measure_directories' do + it 'returns the default LOCAL_MEASURES_DIR when no manifest exists' do + Dir.mktmpdir do |dir| + mgr = manager_for(File.join(dir, 'missing.yml'), dir) + expect(mgr.local_measure_directories).to eq [LOCAL_MEASURES_DIR] + end + end + + it 'expands paths relative to the manifest directory' do + Dir.mktmpdir do |dir| + # Create a real sub-directory so Dir.exist? passes + measures_dir = File.join(dir, 'my_measures') + FileUtils.mkdir_p(measures_dir) + + yaml = "local_measure_roots:\n - my_measures\n" + path = write_manifest(dir, yaml) + + result = manager_for(path, dir).local_measure_directories + expect(result).to eq [measures_dir] + end + end + + it 'filters out paths that do not exist' do + Dir.mktmpdir do |dir| + yaml = "local_measure_roots:\n - nonexistent_dir\n" + path = write_manifest(dir, yaml) + + result = manager_for(path, dir).local_measure_directories + expect(result).to be_empty + end + end + + it 'deduplicates identical paths' do + Dir.mktmpdir do |dir| + measures_dir = File.join(dir, 'measures') + FileUtils.mkdir_p(measures_dir) + + yaml = "local_measure_roots:\n - measures\n - measures\n" + path = write_manifest(dir, yaml) + + result = manager_for(path, dir).local_measure_directories + expect(result).to eq [measures_dir] + end + end + + it 'falls back to LOCAL_MEASURES_DIR when local_measure_roots is empty' do + Dir.mktmpdir do |dir| + yaml = "local_measure_roots: []\n" + path = write_manifest(dir, yaml) + + result = manager_for(path, dir).local_measure_directories + expect(result).to eq [LOCAL_MEASURES_DIR] + end + end + end + + # --------------------------------------------------------------------------- + describe '#resolved_measure_directories' do + it 'returns empty array when manifest does not exist' do + Dir.mktmpdir do |dir| + mgr = manager_for(File.join(dir, 'missing.yml'), dir) + expect(mgr.resolved_measure_directories).to eq [] + end + end + + it 'returns empty array when repos list is empty' do + Dir.mktmpdir do |dir| + path = write_manifest(dir, "repos: []\n") + expect(manager_for(path, dir).resolved_measure_directories).to eq [] + end + end + + it 'returns existing measure root directories for installed repos' do + Dir.mktmpdir do |dir| + install_dir = File.join(dir, 'vendor') + repo_root = File.join(install_dir, 'my_repo') + mroot = File.join(repo_root, 'measures') + FileUtils.mkdir_p(mroot) + + yaml = <<~YAML + repos: + - name: my_repo + url: https://example.com/repo.git + ref: main + measure_roots: + - measures + YAML + path = write_manifest(dir, yaml) + + result = manager_for(path, install_dir).resolved_measure_directories + expect(result).to eq [mroot] + end + end + + it 'skips repo if checkout directory does not exist' do + Dir.mktmpdir do |dir| + install_dir = File.join(dir, 'vendor') + FileUtils.mkdir_p(install_dir) + + yaml = <<~YAML + repos: + - name: missing_repo + url: https://example.com/repo.git + ref: main + measure_roots: + - measures + YAML + path = write_manifest(dir, yaml) + + # Silence the OpenStudio warning — stub module + constant + stub_const('OpenStudio', Module.new) + stub_const('OpenStudio::Warn', 0) + allow(OpenStudio).to receive(:logFree) + + result = manager_for(path, install_dir).resolved_measure_directories + expect(result).to eq [] + end + end + + it 'skips a measure_root sub-path that does not exist inside the repo' do + Dir.mktmpdir do |dir| + install_dir = File.join(dir, 'vendor') + repo_root = File.join(install_dir, 'my_repo') + FileUtils.mkdir_p(repo_root) # repo exists but measure sub-dir does not + + yaml = <<~YAML + repos: + - name: my_repo + url: https://example.com/repo.git + ref: main + measure_roots: + - measures + YAML + path = write_manifest(dir, yaml) + + stub_const('OpenStudio', Module.new) + stub_const('OpenStudio::Warn', 0) + allow(OpenStudio).to receive(:logFree) + + result = manager_for(path, install_dir).resolved_measure_directories + expect(result).to eq [] + end + end + + it 'deduplicates identical resolved paths across repos' do + Dir.mktmpdir do |dir| + install_dir = File.join(dir, 'vendor') + repo_root = File.join(install_dir, 'repo_a') + mroot = File.join(repo_root, 'measures') + FileUtils.mkdir_p(mroot) + + # Two entries pointing at the same resolved path + yaml = <<~YAML + repos: + - name: repo_a + url: https://example.com/a.git + ref: main + measure_roots: + - measures + - measures + YAML + path = write_manifest(dir, yaml) + + result = manager_for(path, install_dir).resolved_measure_directories + expect(result).to eq [mroot] + end + end + + it 'returns multiple measure roots from one repo' do + Dir.mktmpdir do |dir| + install_dir = File.join(dir, 'vendor') + repo_root = File.join(install_dir, 'my_repo') + mroot_a = File.join(repo_root, 'measures') + mroot_b = File.join(repo_root, 'resources', 'measures') + FileUtils.mkdir_p(mroot_a) + FileUtils.mkdir_p(mroot_b) + + yaml = <<~YAML + repos: + - name: my_repo + url: https://example.com/repo.git + ref: main + measure_roots: + - measures + - resources/measures + YAML + path = write_manifest(dir, yaml) + + result = manager_for(path, install_dir).resolved_measure_directories + expect(result).to contain_exactly(mroot_a, mroot_b) + end + end + end + + # --------------------------------------------------------------------------- + describe 'manifest validation' do + it 'raises when repos key is not an array' do + Dir.mktmpdir do |dir| + path = write_manifest(dir, "repos: not_an_array\n") + expect { manager_for(path, dir).configured? }.to raise_error(StandardError, /Expected 'repos' array/) + end + end + + it 'raises when a repo entry is missing required keys' do + Dir.mktmpdir do |dir| + yaml = <<~YAML + repos: + - name: incomplete_repo + url: https://example.com/repo.git + YAML + path = write_manifest(dir, yaml) + expect { manager_for(path, dir).configured? }.to raise_error(StandardError, /missing required keys/) + end + end + + it 'raises when measure_roots is empty' do + Dir.mktmpdir do |dir| + yaml = <<~YAML + repos: + - name: my_repo + url: https://example.com/repo.git + ref: main + measure_roots: [] + YAML + path = write_manifest(dir, yaml) + expect { manager_for(path, dir).configured? }.to raise_error(StandardError, /non-empty measure_roots/) + end + end + + it 'raises when a repo entry is not a hash' do + Dir.mktmpdir do |dir| + yaml = "repos:\n - just_a_string\n" + path = write_manifest(dir, yaml) + expect { manager_for(path, dir).configured? }.to raise_error(StandardError, /must be a map/) + end + end + end +end