From e64d95981acb7620f680c539e87b730e186249d6 Mon Sep 17 00:00:00 2001 From: Jon Iles Date: Fri, 8 Nov 2024 18:42:56 +0000 Subject: [PATCH] Add access to calendar data to the MPXJ gem (#770) --- src.ruby/mpxj/.idea/.gitignore | 8 ++ src.ruby/mpxj/.idea/misc.xml | 4 + src.ruby/mpxj/.idea/modules.xml | 8 ++ src.ruby/mpxj/.idea/mpxj.iml | 65 ++++++++++ src.ruby/mpxj/.idea/vcs.xml | 6 + src.ruby/mpxj/.rubocop.yml | 3 - src.ruby/mpxj/lib/mpxj.rb | 5 + src.ruby/mpxj/lib/mpxj/calendar.rb | 112 ++++++++++++++++++ src.ruby/mpxj/lib/mpxj/calendar_day.rb | 26 ++++ src.ruby/mpxj/lib/mpxj/calendar_exception.rb | 47 ++++++++ src.ruby/mpxj/lib/mpxj/calendar_hours.rb | 19 +++ src.ruby/mpxj/lib/mpxj/calendar_week.rb | 38 ++++++ src.ruby/mpxj/lib/mpxj/container.rb | 8 ++ src.ruby/mpxj/lib/mpxj/project.rb | 24 +++- src.ruby/mpxj/spec/calendar.mpp | Bin 0 -> 180736 bytes src.ruby/mpxj/spec/calendar_day_spec.rb | 33 ++++++ src.ruby/mpxj/spec/calendar_exception_spec.rb | 72 +++++++++++ src.ruby/mpxj/spec/calendar_hours_spec.rb | 33 ++++++ src.ruby/mpxj/spec/calendar_spec.rb | 112 ++++++++++++++++++ src.ruby/mpxj/spec/calendar_week_spec.rb | 80 +++++++++++++ src.ruby/mpxj/spec/project_spec.rb | 12 ++ src/changes/changes.xml | 1 + 22 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 src.ruby/mpxj/.idea/.gitignore create mode 100644 src.ruby/mpxj/.idea/misc.xml create mode 100644 src.ruby/mpxj/.idea/modules.xml create mode 100644 src.ruby/mpxj/.idea/mpxj.iml create mode 100644 src.ruby/mpxj/.idea/vcs.xml create mode 100644 src.ruby/mpxj/lib/mpxj/calendar.rb create mode 100644 src.ruby/mpxj/lib/mpxj/calendar_day.rb create mode 100644 src.ruby/mpxj/lib/mpxj/calendar_exception.rb create mode 100644 src.ruby/mpxj/lib/mpxj/calendar_hours.rb create mode 100644 src.ruby/mpxj/lib/mpxj/calendar_week.rb create mode 100644 src.ruby/mpxj/spec/calendar.mpp create mode 100644 src.ruby/mpxj/spec/calendar_day_spec.rb create mode 100644 src.ruby/mpxj/spec/calendar_exception_spec.rb create mode 100644 src.ruby/mpxj/spec/calendar_hours_spec.rb create mode 100644 src.ruby/mpxj/spec/calendar_spec.rb create mode 100644 src.ruby/mpxj/spec/calendar_week_spec.rb diff --git a/src.ruby/mpxj/.idea/.gitignore b/src.ruby/mpxj/.idea/.gitignore new file mode 100644 index 0000000000..13566b81b0 --- /dev/null +++ b/src.ruby/mpxj/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src.ruby/mpxj/.idea/misc.xml b/src.ruby/mpxj/.idea/misc.xml new file mode 100644 index 0000000000..1e4ee95ca9 --- /dev/null +++ b/src.ruby/mpxj/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src.ruby/mpxj/.idea/modules.xml b/src.ruby/mpxj/.idea/modules.xml new file mode 100644 index 0000000000..6022973f80 --- /dev/null +++ b/src.ruby/mpxj/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src.ruby/mpxj/.idea/mpxj.iml b/src.ruby/mpxj/.idea/mpxj.iml new file mode 100644 index 0000000000..9d889d58ef --- /dev/null +++ b/src.ruby/mpxj/.idea/mpxj.iml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src.ruby/mpxj/.idea/vcs.xml b/src.ruby/mpxj/.idea/vcs.xml new file mode 100644 index 0000000000..b2bdec2d71 --- /dev/null +++ b/src.ruby/mpxj/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src.ruby/mpxj/.rubocop.yml b/src.ruby/mpxj/.rubocop.yml index 6187738a86..2314fc9857 100644 --- a/src.ruby/mpxj/.rubocop.yml +++ b/src.ruby/mpxj/.rubocop.yml @@ -194,9 +194,6 @@ StringLiterals: HashSyntax: Enabled: false -VariableInterpolation: - Enabled: false - TrailingComma: Enabled: false diff --git a/src.ruby/mpxj/lib/mpxj.rb b/src.ruby/mpxj/lib/mpxj.rb index c68053c4e4..331820d720 100644 --- a/src.ruby/mpxj/lib/mpxj.rb +++ b/src.ruby/mpxj/lib/mpxj.rb @@ -3,6 +3,11 @@ require "mpxj/project" require "mpxj/property_methods" require "mpxj/properties" +require "mpxj/calendar" +require "mpxj/calendar_day" +require "mpxj/calendar_week" +require "mpxj/calendar_hours" +require "mpxj/calendar_exception" require "mpxj/resource_methods" require "mpxj/resource" require "mpxj/task_methods" diff --git a/src.ruby/mpxj/lib/mpxj/calendar.rb b/src.ruby/mpxj/lib/mpxj/calendar.rb new file mode 100644 index 0000000000..fa98f8179e --- /dev/null +++ b/src.ruby/mpxj/lib/mpxj/calendar.rb @@ -0,0 +1,112 @@ +module MPXJ + # Represents a calendar + class Calendar < Container + attr_reader :days + attr_reader :weeks + attr_reader :exceptions + + def initialize(parent_project, attribute_values) + super(parent_project, attribute_values.slice('unique_id', 'guid', 'parent_unique_id', 'name', 'type', 'personal', 'minutes_per_day', 'minutes_per_week', 'minutes_per_month', 'minutes_per_year')) + process_days(attribute_values) + process_weeks(attribute_values) + process_exceptions(attribute_values) + end + + # Retrieve the calendar unique ID + # + # @return [Integer] the calendar unique ID + def unique_id + get_integer_value(attribute_values['unique_id']) + end + + # Retrieve the calendar GUID + # + # @return [String] the calendar GUID + def guid + attribute_values['guid'] + end + + # Retrieve the parent calendar unique ID + # + # @return [Integer] the parent calendar unique ID + # @return [nil] if the calendar does not have a parent + def parent_unique_id + get_nillable_integer_value(attribute_values['parent_unique_id']) + end + + # Retrieve the parent calendar of this calendar + # + # @return [Calendar] if this calendar is the child of another calendar + # @return [nil] if this is a base calendar + def parent_calendar + parent_project.get_calendar_by_unique_id(attribute_values['parent_unique_id']&.to_i) + end + + # Retrieve the calendar name + # + # @return [String] the calendar name + def name + attribute_values['name'] + end + + # Retrieve the calendar type + # + # @return [String] the calendar type + def type + attribute_values['type'] + end + + # Retrieve the personal flag + # + # @return [Boolean] true if this is a personal calendar + def personal + get_boolean_value(attribute_values['personal']) + end + + # Retrieve the number of minutes per day + # + # @return [Integer] the number of minutes per day + # @return [nil] if this calendar does not provide a value for minutes per day + def minutes_per_day + get_nillable_integer_value(attribute_values['minutes_per_day']) + end + + # Retrieve the number of minutes per week + # + # @return [Integer] the number of minutes per week + # @return [nil] if this calendar does not provide a value for minutes per week + def minutes_per_week + get_nillable_integer_value(attribute_values['minutes_per_week']) + end + + # Retrieve the number of minutes per month + # + # @return [Integer] the number of minutes per month + # @return [nil] if this calendar does not provide a value for minutes per month + def minutes_per_month + get_nillable_integer_value(attribute_values['minutes_per_month']) + end + + # Retrieve the number of minutes per year + # + # @return [Integer] the number of minutes per year + # @return [nil] if this calendar does not provide a value for minutes per year + def minutes_per_year + get_nillable_integer_value(attribute_values['minutes_per_year']) + end + + private + + def process_days(attribute_values) + @days = attribute_values.slice('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday').map {|name, day| [name, CalendarDay.new(parent_project, day)]}.to_h + end + + def process_weeks(attribute_values) + @weeks = (attribute_values['working_weeks'] || []).map {|week| CalendarWeek.new(parent_project, week)} + end + + def process_exceptions(attribute_values) + @exceptions = (attribute_values['exceptions'] || []).map {|exception| CalendarException.new(parent_project, exception)} + end + end +end diff --git a/src.ruby/mpxj/lib/mpxj/calendar_day.rb b/src.ruby/mpxj/lib/mpxj/calendar_day.rb new file mode 100644 index 0000000000..7dba1748bb --- /dev/null +++ b/src.ruby/mpxj/lib/mpxj/calendar_day.rb @@ -0,0 +1,26 @@ +module MPXJ + # Represents a calendar day + class CalendarDay < Container + attr_reader :hours + + def initialize(parent_project, attribute_values) + super(parent_project, attribute_values.slice('type')) + process_hours(attribute_values) + end + + # Retrieve the day type + # + # @return [String] the calendar day type + def type + attribute_values['type'] + end + + private + + def process_hours(attribute_values) + @hours = (attribute_values['hours'] || {}).map do |hours| + CalendarHours.new(parent_project, hours) + end + end + end +end diff --git a/src.ruby/mpxj/lib/mpxj/calendar_exception.rb b/src.ruby/mpxj/lib/mpxj/calendar_exception.rb new file mode 100644 index 0000000000..e9cf1f5bc7 --- /dev/null +++ b/src.ruby/mpxj/lib/mpxj/calendar_exception.rb @@ -0,0 +1,47 @@ +module MPXJ + # Represents a calendar exception + class CalendarException < Container + attr_reader :hours + + def initialize(parent_project, attribute_values) + super(parent_project, attribute_values.slice('name', 'from', 'to', 'type')) + process_hours(attribute_values) + end + + # Retrieve the exception name + # + # @return [String] the exception name + def name + attribute_values['name'] + end + + # Retrieve the date on which this exception starts + # + # @return [Time] the exception from date + def from + get_date_value(attribute_values['from']) + end + + # Retrieve the date on which this exception ends + # + # @return [Time] the exception to date + def to + get_date_value(attribute_values['to']) + end + + # Retrieve the exception type + # + # @return [String] the exception type + def type + attribute_values['type'] + end + + private + + def process_hours(attribute_values) + @hours = (attribute_values['hours'] || {}).map do |hours| + CalendarHours.new(parent_project, hours) + end + end + end +end diff --git a/src.ruby/mpxj/lib/mpxj/calendar_hours.rb b/src.ruby/mpxj/lib/mpxj/calendar_hours.rb new file mode 100644 index 0000000000..c6b3cb02f4 --- /dev/null +++ b/src.ruby/mpxj/lib/mpxj/calendar_hours.rb @@ -0,0 +1,19 @@ +module MPXJ + # Represents a range of hours + class CalendarHours < Container + + # Retrieve the the start hour + # + # @return [Time] start hour + def from + get_date_value(attribute_values['from']) + end + + # Retrieve the the finish hour + # + # @return [Time] finish hour + def to + get_date_value(attribute_values['to']) + end + end +end diff --git a/src.ruby/mpxj/lib/mpxj/calendar_week.rb b/src.ruby/mpxj/lib/mpxj/calendar_week.rb new file mode 100644 index 0000000000..5818544664 --- /dev/null +++ b/src.ruby/mpxj/lib/mpxj/calendar_week.rb @@ -0,0 +1,38 @@ +module MPXJ + # Represents a working week + class CalendarWeek < Container + attr_reader :days + + def initialize(parent_project, attribute_values) + super(parent_project, attribute_values.slice('name', 'effective_from', 'effective_to')) + process_days(attribute_values) + end + + # Retrieve the exception name + # + # @return [String] the exception name + def name + attribute_values['name'] + end + + # Retrieve the date from which this working week is in effect + # + # @return [Time] effective from date + def effective_from + get_date_value(attribute_values['effective_from']) + end + + # Retrieve the date to which this working week is in effect + # + # @return [Time] effective to date + def effective_to + get_date_value(attribute_values['effective_to']) + end + + private + + def process_days(attribute_values) + @days = attribute_values.slice('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday').map {|name, day| [name, CalendarDay.new(parent_project, day)]}.to_h + end + end +end diff --git a/src.ruby/mpxj/lib/mpxj/container.rb b/src.ruby/mpxj/lib/mpxj/container.rb index df499a41c4..55fdce2afc 100644 --- a/src.ruby/mpxj/lib/mpxj/container.rb +++ b/src.ruby/mpxj/lib/mpxj/container.rb @@ -45,6 +45,14 @@ def get_integer_value(attribute_value) end end + def get_nillable_integer_value(attribute_value) + if attribute_value.nil? + nil + else + attribute_value.to_i + end + end + def get_boolean_value(attribute_value) attribute_value == true end diff --git a/src.ruby/mpxj/lib/mpxj/project.rb b/src.ruby/mpxj/lib/mpxj/project.rb index 278bba1542..251dcb2a24 100644 --- a/src.ruby/mpxj/lib/mpxj/project.rb +++ b/src.ruby/mpxj/lib/mpxj/project.rb @@ -4,6 +4,7 @@ module MPXJ # Represents a project plan class Project attr_reader :properties + attr_reader :all_calendars attr_reader :all_resources attr_reader :all_tasks attr_reader :child_tasks @@ -11,12 +12,14 @@ class Project attr_reader :zone def initialize(file_name, zone) + @calendars_by_unique_id = {} @resources_by_unique_id = {} @tasks_by_unique_id = {} - @resources_by_id = {} + @resources_by_id = {} @tasks_by_id = {} + @all_calendars = [] @all_resources = [] @all_tasks = [] @all_assignments = [] @@ -29,6 +32,7 @@ def initialize(file_name, zone) file = File.read(file_name) json_data = JSON.parse(file) + process_calendars(json_data) process_custom_fields(json_data) process_properties(json_data) process_resources(json_data) @@ -36,6 +40,15 @@ def initialize(file_name, zone) process_assignments(json_data) end + # Retrieves the calendar with the matching unique_id attribute + # + # @param unique_id [Integer] calendar unique ID + # @return [Calendar] if the requested calendar is found + # @return [nil] if the requested calendar is not found + def get_calendar_by_unique_id(unique_id) + @calendars_by_unique_id[unique_id] + end + # Retrieves the resource with the matching unique_id attribute # # @param unique_id [Integer] resource unique ID @@ -104,6 +117,15 @@ def get_alias_by_field(field_type_class, field_type) private + def process_calendars(json_data) + calendars = json_data["calendars"] + calendars.each do |attribute_values| + calendar = Calendar.new(self, attribute_values) + @all_calendars << calendar + @calendars_by_unique_id[calendar.unique_id] = calendar + end + end + def process_custom_fields(json_data) custom_fields = json_data["custom_fields"] || [] custom_fields.each do |field| diff --git a/src.ruby/mpxj/spec/calendar.mpp b/src.ruby/mpxj/spec/calendar.mpp new file mode 100644 index 0000000000000000000000000000000000000000..9c03ea631adbd7aebcd2f146f25b584dfd32981f GIT binary patch literal 180736 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�q<(E+Z*CO7=jJ@pce`&o-s{d=? zR_d#mRQ^kU|F=Ontsy0ie<|FGh}jlxIz5lmI`MdFNkMdszU6pnD=WK=kZ-CpfEdS03uhUk<@9O`5vHTx{ zu4H~djLCoL+8*)m_y4-%yrk{#a=0za_P;B_>#`N`KLoe()+A

    d#0S4NG+Y>)eY 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 J%s)#5{|8rB^3DJN literal 0 HcmV?d00001 diff --git a/src.ruby/mpxj/spec/calendar_day_spec.rb b/src.ruby/mpxj/spec/calendar_day_spec.rb new file mode 100644 index 0000000000..9814f981bf --- /dev/null +++ b/src.ruby/mpxj/spec/calendar_day_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe MPXJ::CalendarDay do + before :each do + @project = MPXJ::Reader.read("#{File.dirname(__FILE__)}/calendar.mpp") + end + + describe "#type" do + it 'returns correct type' do + days = @project.get_calendar_by_unique_id(1).days + expect(days.count).to eq(7) + + day = days['sunday'] + expect(day.type).to eq('non_working') + + day = days['monday'] + expect(day.type).to eq('working') + end + end + + describe "#hours" do + it 'returns correct number of hours' do + days = @project.get_calendar_by_unique_id(1).days + expect(days.count).to eq(7) + + day = days['sunday'] + expect(day.hours.count).to eq(0) + + day = days['monday'] + expect(day.hours.count).to eq(2) + end + end +end diff --git a/src.ruby/mpxj/spec/calendar_exception_spec.rb b/src.ruby/mpxj/spec/calendar_exception_spec.rb new file mode 100644 index 0000000000..d8c0df05fe --- /dev/null +++ b/src.ruby/mpxj/spec/calendar_exception_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe MPXJ::CalendarException do + before :each do + @project = MPXJ::Reader.read("#{File.dirname(__FILE__)}/calendar.mpp") + end + + describe "#name" do + it 'returns correct name' do + exceptions = @project.get_calendar_by_unique_id(3).exceptions + expect(exceptions.count).to eq(2) + + exception = exceptions[0] + expect(exception.name).to eq('Working Day') + + exception = exceptions[1] + expect(exception.name).to eq('Nonworking Day') + end + end + + describe "#from" do + it 'returns correct date' do + exceptions = @project.get_calendar_by_unique_id(3).exceptions + expect(exceptions.count).to eq(2) + + exception = exceptions[0] + expect(exception.from.strftime('%Y-%m-%d')).to eq('2025-01-11') + + exception = exceptions[1] + expect(exception.from.strftime('%Y-%m-%d')).to eq('2025-01-13') + end + end + + describe "#to" do + it 'returns correct date' do + exceptions = @project.get_calendar_by_unique_id(3).exceptions + expect(exceptions.count).to eq(2) + + exception = exceptions[0] + expect(exception.to.strftime('%Y-%m-%d')).to eq('2025-01-11') + + exception = exceptions[1] + expect(exception.to.strftime('%Y-%m-%d')).to eq('2025-01-13') + end + end + + describe "#type" do + it 'returns correct type' do + exceptions = @project.get_calendar_by_unique_id(3).exceptions + expect(exceptions.count).to eq(2) + + exception = exceptions[0] + expect(exception.type).to eq('working') + + exception = exceptions[1] + expect(exception.type).to eq('non_working') + end + end + + describe "#hours" do + it 'returns correct number of hours' do + exceptions = @project.get_calendar_by_unique_id(3).exceptions + expect(exceptions.count).to eq(2) + + exception = exceptions[0] + expect(exception.hours.count).to eq(2) + + exception = exceptions[1] + expect(exception.hours.count).to eq(0) + end + end +end diff --git a/src.ruby/mpxj/spec/calendar_hours_spec.rb b/src.ruby/mpxj/spec/calendar_hours_spec.rb new file mode 100644 index 0000000000..63fbefd6d2 --- /dev/null +++ b/src.ruby/mpxj/spec/calendar_hours_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe MPXJ::CalendarHours do + before :each do + @project = MPXJ::Reader.read("#{File.dirname(__FILE__)}/calendar.mpp") + end + + describe "#from" do + it 'returns correct from time' do + days = @project.get_calendar_by_unique_id(1).days + expect(days.count).to eq(7) + + hours = days['monday'].hours + expect(hours.count).to eq(2) + + expect(hours[0].from.strftime('%H:%M')).to eq('08:00') + expect(hours[1].from.strftime('%H:%M')).to eq('13:00') + end + end + + describe "#to" do + it 'returns correct to time' do + days = @project.get_calendar_by_unique_id(1).days + expect(days.count).to eq(7) + + hours = days['monday'].hours + expect(hours.count).to eq(2) + + expect(hours[0].to.strftime('%H:%M')).to eq('12:00') + expect(hours[1].to.strftime('%H:%M')).to eq('17:00') + end + end +end diff --git a/src.ruby/mpxj/spec/calendar_spec.rb b/src.ruby/mpxj/spec/calendar_spec.rb new file mode 100644 index 0000000000..11aa165561 --- /dev/null +++ b/src.ruby/mpxj/spec/calendar_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +describe MPXJ::Calendar do + before :each do + @project = MPXJ::Reader.read("#{File.dirname(__FILE__)}/calendar.mpp") + end + + describe "#unique_id" do + it 'returns correct unique id' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.unique_id).to eq(1) + end + end + + describe "#guid" do + it 'returns correct guid' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.guid).to eq('073aaf7a-ed5e-4fbe-ad97-f2cf43cea217') + end + end + + describe "#parent_unique_id" do + it 'returns correct parent_unique_id' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.parent_unique_id).to eq(nil) + + calendar = @project.get_calendar_by_unique_id(2) + expect(calendar.parent_unique_id).to eq(1) + end + end + + describe "#parent_calendar" do + it 'returns correct calendar instance' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.parent_calendar).to eq(nil) + + calendar = @project.get_calendar_by_unique_id(2) + expect(calendar.parent_calendar.unique_id).to eq(1) + end + end + + describe "#name" do + it 'returns correct name' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.name).to eq('Standard') + end + end + + describe "#type" do + it 'returns correct type' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.type).to eq('GLOBAL') + end + end + + describe "#personal" do + it 'returns correct personal flag' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.personal).to eq(false) + end + end + + describe "#minutes_per_day" do + it 'returns correct minutes_per_day' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.minutes_per_day).to eq(nil) + end + end + + describe "#minutes_per_week" do + it 'returns correct minutes_per_week' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.minutes_per_week).to eq(nil) + end + end + + describe "#minutes_per_month" do + it 'returns correct minutes_per_month' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.minutes_per_month).to eq(nil) + end + end + + + describe "#minutes_per_year" do + it 'returns correct minutes_per_year' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.minutes_per_year).to eq(nil) + end + end + + describe "#days" do + it 'returns correct number of days' do + calendar = @project.get_calendar_by_unique_id(1) + expect(calendar.days.count).to eq(7) + end + end + + describe "#weeks" do + it 'returns correct number of weeks' do + calendar = @project.get_calendar_by_unique_id(3) + expect(calendar.weeks.count).to eq(2) + end + end + + describe "#exceptions" do + it 'returns correct number of exceptions' do + calendar = @project.get_calendar_by_unique_id(3) + expect(calendar.exceptions.count).to eq(2) + end + end +end diff --git a/src.ruby/mpxj/spec/calendar_week_spec.rb b/src.ruby/mpxj/spec/calendar_week_spec.rb new file mode 100644 index 0000000000..659f71c81d --- /dev/null +++ b/src.ruby/mpxj/spec/calendar_week_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe MPXJ::CalendarWeek do + before :each do + @project = MPXJ::Reader.read("#{File.dirname(__FILE__)}/calendar.mpp") + end + + describe "#name" do + it 'returns correct name' do + weeks = @project.get_calendar_by_unique_id(3).weeks + expect(weeks.count).to eq(2) + + week = weeks[0] + expect(week.name).to eq('Work Week 1') + + week = weeks[1] + expect(week.name).to eq('Work Week 2') + end + end + + describe "#effective_from" do + it 'returns correct date' do + weeks = @project.get_calendar_by_unique_id(3).weeks + expect(weeks.count).to eq(2) + + week = weeks[0] + expect(week.effective_from.strftime('%Y-%m-%d')).to eq('2024-10-01') + + week = weeks[1] + expect(week.effective_from.strftime('%Y-%m-%d')).to eq('2024-12-01') + end + end + + describe "#effective_to" do + it 'returns correct date' do + weeks = @project.get_calendar_by_unique_id(3).weeks + expect(weeks.count).to eq(2) + + week = weeks[0] + expect(week.effective_to.strftime('%Y-%m-%d')).to eq('2024-10-31') + + week = weeks[1] + expect(week.effective_to.strftime('%Y-%m-%d')).to eq('2024-12-31') + end + end + + describe "#days" do + it 'returns correct day definitions' do + weeks = @project.get_calendar_by_unique_id(3).weeks + expect(weeks.count).to eq(2) + + days = weeks[0].days + expect(days.count).to eq(7) + + day = days['sunday'] + expect(day.type).to eq('default') + expect(day.hours.count).to eq(0) + + day = days['monday'] + expect(day.type).to eq('working') + expect(day.hours.count).to eq(1) + expect(day.hours[0].from.strftime('%H:%M')).to eq('08:00') + expect(day.hours[0].to.strftime('%H:%M')).to eq('12:00') + + + days = weeks[1].days + expect(days.count).to eq(7) + + day = days['sunday'] + expect(day.type).to eq('working') + expect(day.hours.count).to eq(1) + expect(day.hours[0].from.strftime('%H:%M')).to eq('08:00') + expect(day.hours[0].to.strftime('%H:%M')).to eq('17:00') + + day = days['monday'] + expect(day.type).to eq('non_working') + expect(day.hours.count).to eq(0) + end + end +end diff --git a/src.ruby/mpxj/spec/project_spec.rb b/src.ruby/mpxj/spec/project_spec.rb index 03d0805f78..4cd539cb15 100644 --- a/src.ruby/mpxj/spec/project_spec.rb +++ b/src.ruby/mpxj/spec/project_spec.rb @@ -5,6 +5,12 @@ @project = MPXJ::Reader.read("#{File.dirname(__FILE__)}/project.mpp") end + describe "#all_calendars" do + it 'returns the number of calendars' do + expect(@project.all_calendars.size).to eq(5) + end + end + describe "#all_resources" do it 'returns the number of resources' do expect(@project.all_resources.size).to eq(4) @@ -29,6 +35,12 @@ end end + describe "#get_calendar_by_unique_id" do + it 'returns the correct calendar' do + expect(@project.get_calendar_by_unique_id(1).unique_id).to eq(1) + end + end + describe "#get_resource_by_id" do it 'returns the correct resource' do expect(@project.get_resource_by_id(1).id).to eq(1) diff --git a/src/changes/changes.xml b/src/changes/changes.xml index d672acbd18..2a7e0f0e9a 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -6,6 +6,7 @@ + Update the MPXJ ruby gem to allow access to calendar data. Added the `Task.getBaselineTask()` methods. For applications where a separate baseline schedule is present or a baseline has been manually added to the `ProjectFile` instance, these methods will allow you to access the underlying baseline task instance from the current task instance.