From d044c908e078d46e3eaa45e7ee59cc4bbb039e0a Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Thu, 4 Jan 2024 10:52:15 +0100 Subject: [PATCH] Introduce let_work_packages helpers It allows to declare work packages and their properties from a visual table. It is especially useful to visualize hierarchies and data as if it were presented in the work package table page in OpenProject app. --- .../update_ancestors_service_spec.rb | 99 +++++----- spec/support/table_helpers.rb | 51 +++++ spec/support/table_helpers/column.rb | 176 ++++++++++++++++++ spec/support/table_helpers/example_methods.rb | 79 ++++++++ spec/support/table_helpers/identifier.rb | 43 +++++ .../table_helpers/let_work_packages.rb | 70 +++++++ spec/support/table_helpers/table.rb | 52 ++++++ spec/support/table_helpers/table_data.rb | 121 ++++++++++++ spec/support/table_helpers/table_parser.rb | 63 +++++++ .../table_helpers/table_representer.rb | 63 +++++++ .../support_spec/table_helpers/column_spec.rb | 87 +++++++++ .../table_helpers/identifier_spec.rb | 46 +++++ .../table_helpers/table_data_spec.rb | 85 +++++++++ .../table_helpers/table_parser_spec.rb | 150 +++++++++++++++ .../table_helpers/table_representer_spec.rb | 104 +++++++++++ 15 files changed, 1243 insertions(+), 46 deletions(-) create mode 100644 spec/support/table_helpers.rb create mode 100644 spec/support/table_helpers/column.rb create mode 100644 spec/support/table_helpers/example_methods.rb create mode 100644 spec/support/table_helpers/identifier.rb create mode 100644 spec/support/table_helpers/let_work_packages.rb create mode 100644 spec/support/table_helpers/table.rb create mode 100644 spec/support/table_helpers/table_data.rb create mode 100644 spec/support/table_helpers/table_parser.rb create mode 100644 spec/support/table_helpers/table_representer.rb create mode 100644 spec/support_spec/table_helpers/column_spec.rb create mode 100644 spec/support_spec/table_helpers/identifier_spec.rb create mode 100644 spec/support_spec/table_helpers/table_data_spec.rb create mode 100644 spec/support_spec/table_helpers/table_parser_spec.rb create mode 100644 spec/support_spec/table_helpers/table_representer_spec.rb diff --git a/spec/services/work_packages/update_ancestors_service_spec.rb b/spec/services/work_packages/update_ancestors_service_spec.rb index 3e377e1eff2..7e34e24c36e 100644 --- a/spec/services/work_packages/update_ancestors_service_spec.rb +++ b/spec/services/work_packages/update_ancestors_service_spec.rb @@ -575,67 +575,74 @@ RSpec.describe WorkPackages::UpdateAncestorsService, type: :model do end context 'for the new ancestors chain' do - shared_context 'when called with children' do |children_remaining_hours:| - let(:children) do - (children_remaining_hours.size - 1).downto(0).map do |i| - create(:work_package, - subject: "child #{i}", - parent:, - status: open_status, - remaining_hours: children_remaining_hours[i], - derived_remaining_hours: children_remaining_hours[i]) - end - end + context 'with parent having no remaining work' do + let_work_packages(<<~TABLE) + hierarchy | remaining work | + parent | | + child1 | 0h | + child2 | | + child3 | 2.5h | + TABLE + subject(:call_result) do - described_class.new(user:, work_package: children.first) + described_class.new(user:, work_package: child1) .call(%i(remaining_hours)) end - end - shared_examples 'derived remaining hours' do |children_remaining_hours:, expected_derived_remaining_hours:| - context "for #{children_remaining_hours.count} children with remaining hours being #{children_remaining_hours.inspect}" do - include_context('when called with children', children_remaining_hours:) - - it "sets parent derived remaining hours to #{expected_derived_remaining_hours}", :aggregate_failures do - expect(call_result).to be_success - expect(call_result.dependent_results.map(&:result)) - .to contain_exactly(parent) - expect(call_result.dependent_results.first.result.derived_remaining_hours) - .to eq(expected_derived_remaining_hours) - end + it 'sets parent derived remaining work to the sum of children remaining work' do + expect(call_result).to be_success + updated_work_packages = call_result.dependent_results.map(&:result) + expect_work_packages(updated_work_packages, <<~TABLE) + subject | derived remaining work + parent | 2.5h + TABLE end end - context 'with parent having no remaining hours' do - include_examples 'derived remaining hours', - children_remaining_hours: [0.0, 2.0, nil], - expected_derived_remaining_hours: 2.0 + context 'with parent having some remaining work' do + let_work_packages(<<~TABLE) + hierarchy | remaining work | + parent | 5.25h | + child1 | 0h | + child2 | | + child3 | 2.5h | + TABLE - include_examples 'derived remaining hours', - children_remaining_hours: [1, 2, 5, 42], - expected_derived_remaining_hours: 50 - end - - context 'with parent having 2.0 remaining hours' do - before do - parent.update(remaining_hours: 2.0) + subject(:call_result) do + described_class.new(user:, work_package: child1) + .call(%i(remaining_hours)) end - include_examples 'derived remaining hours', - children_remaining_hours: [0.0, 2.0, nil], - expected_derived_remaining_hours: 4.0 - - include_examples 'derived remaining hours', - children_remaining_hours: [1, 2, 5, 42], - expected_derived_remaining_hours: 52 + it 'sets parent derived remaining work to the sum of itself and children remaining work' do + expect(call_result).to be_success + updated_work_packages = call_result.dependent_results.map(&:result) + expect_work_packages(updated_work_packages, <<~TABLE) + subject | derived remaining work + parent | 7.75h + TABLE + end end - context 'with no remaining hours' do - include_context('when called with children', children_remaining_hours: [nil, nil, nil]) + context 'with parent and children having no remaining work' do + let_work_packages(<<~TABLE) + hierarchy | remaining work | + parent | | + child1 | | + child2 | | + TABLE - it 'does not update the parent derived remaining hours' do + subject(:call_result) do + described_class.new(user:, work_package: child1) + .call(%i(remaining_hours)) + end + + it 'does not update the parent derived remaining work' do expect(call_result).to be_success expect(call_result.dependent_results).to be_empty + expect_work_packages([parent.reload], <<~TABLE) + subject | derived remaining work + parent | + TABLE end end end diff --git a/spec/support/table_helpers.rb b/spec/support/table_helpers.rb new file mode 100644 index 00000000000..044bbcee7e8 --- /dev/null +++ b/spec/support/table_helpers.rb @@ -0,0 +1,51 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +Dir[Rails.root.join('spec/support/table_helpers/*.rb')].each { |f| require f } + +RSpec.configure do |config| + config.extend TableHelpers::LetWorkPackages + config.include TableHelpers::ExampleMethods + + RSpec::Matchers.define :match_table do |expected| + match do |actual_work_packages| + expected_data = TableHelpers::TableData.for(expected) + actual_data = TableHelpers::TableData.from_work_packages(actual_work_packages, expected_data.columns) + + representer = TableHelpers::TableRepresenter.new(tables_data: [expected_data, actual_data], + columns: expected_data.columns) + @expected = representer.render(expected_data) + @actual = representer.render(actual_data) + + values_match? @expected, @actual + end + + diffable + attr_reader :expected, :actual + end +end diff --git a/spec/support/table_helpers/column.rb b/spec/support/table_helpers/column.rb new file mode 100644 index 00000000000..a7e5c2b6ae9 --- /dev/null +++ b/spec/support/table_helpers/column.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require_relative 'identifier' + +module TableHelpers + module Column + extend Identifier + + def self.for(header) + case header + when /\A\s*estimated hours/i + raise ArgumentError, 'Please use "work" instead of "estimated hours"' + when /derived estimated hours/i + raise ArgumentError, 'Please use "derived work" instead of "derived estimated hours"' + when /derived remaining hours/i + raise ArgumentError, 'Please use "derived remaining work" instead of "derived remaining hours"' + when /\A\s*remaining hours/i + raise ArgumentError, 'Please use "remaining work" instead of "remaining hours"' + when /\A\s*work/i + Duration.new(header:, attribute: :estimated_hours) + when /derived work/i + Duration.new(header:, attribute: :derived_estimated_hours) + when /\A\s*remaining work/i + Duration.new(header:, attribute: :remaining_hours) + when /derived remaining work/i + Duration.new(header:, attribute: :derived_remaining_hours) + when /subject/ + Subject.new(header:) + when /hierarchy/ + Hierarchy.new(header:) + else + assert_work_package_attribute_exists(header) + Generic.new(header:) + end + end + + def self.assert_work_package_attribute_exists(attribute) + attribute = to_identifier(attribute).to_s + return if WorkPackage.attribute_names.include?(attribute) + + raise ArgumentError, "WorkPackage does not have an attribute named #{attribute.inspect}" + end + + class Generic + include Identifier + + attr_reader :attribute, :title, :raw_header + + def initialize(header:, attribute: nil) + @raw_header = header + @title = header.strip + @attribute = attribute || to_identifier(title) + end + + def format(value) + value.to_s + end + + def cell_format(value, size) + format(value).send(text_align, size) + end + + def parse(raw_value) + raw_value.strip + end + + def text_align + :ljust + end + + def read_and_update_work_packages_data(work_packages_data) + work_packages_data.each do |work_package_data| + work_package_data => { attributes:, row: } + raw_value = row[raw_header] + work_package_data.merge!(metadata_for_value(raw_value)) + attributes.merge!(attributes_for_raw_value(raw_value, work_package_data, work_packages_data)) + end + end + + def attributes_for_raw_value(raw_value, _data, _work_packages_data) + { attribute => parse(raw_value) } + end + + def metadata_for_value(_raw_value) + {} + end + end + + module Identifiable + include Identifier + + def metadata_for_value(raw_value, *) + super.merge(identifier: to_identifier(raw_value)) + end + end + + class Duration < Generic + def text_align + :rjust + end + + def format(value) + if value.nil? + '' + elsif value == value.truncate + "%sh" % value.to_i + else + "%sh" % value + end + end + + def parse(raw_value) + raw_value.blank? ? nil : raw_value.to_f + end + end + + class Subject < Generic + include Identifiable + end + + class Hierarchy < Generic + include Identifiable + + def attributes_for_raw_value(raw_value, data, work_packages_data) + { + parent: find_parent(data, work_packages_data), + subject: parse(raw_value) + } + end + + def metadata_for_value(raw_value) + super.merge(hierarchy_indent: raw_value[/\A */].size) + end + + private + + def find_parent(data, work_packages_data) + return if data[:hierarchy_indent] == 0 + + work_packages_data + .slice(0, data[:index]) + .reverse + .find { _1[:hierarchy_indent] < data[:hierarchy_indent] } + .then { _1&.fetch(:identifier) } + end + end + end +end diff --git a/spec/support/table_helpers/example_methods.rb b/spec/support/table_helpers/example_methods.rb new file mode 100644 index 00000000000..5843b2744ac --- /dev/null +++ b/spec/support/table_helpers/example_methods.rb @@ -0,0 +1,79 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + module ExampleMethods + # Create work packages and relations from a visual chart representation. + # + # For instance: + # + # create_table(<<~TABLE) + # hierarchy | work | + # parent | 1h | + # child | 2.5h | + # another one | | + # TABLE + # + # is equivalent to: + # + # create(:work_package, subject: 'parent', estimated_hours: 1) + # create(:work_package, subject: 'child', parent: parent, estimated_hours: 2.5) + # create(:work_package, subject: 'another one') + def create_table(table_representation) + table_data = TableData.for(table_representation) + table_data.create_work_packages + end + + # Expect the given work packages to match a visual table representation. + # + # It uses +match_table+ internally. + # + # For instance: + # + # it 'is scheduled' do + # expect_work_packages(work_packages, <<~TABLE) + # subject | work | derived work | + # parent | 1h | 3h | + # child | 2h | 2h | + # TABLE + # end + # + # is equivalent to: + # + # it 'is scheduled' do + # expect(work_packages).to match_table(<<~TABLE) + # subject | work | derived work | + # parent | 1h | 3h | + # child | 2h | 2h | + # TABLE + # end + def expect_work_packages(work_packages, table_representation) + expect(work_packages).to match_table(table_representation) + end + end +end diff --git a/spec/support/table_helpers/identifier.rb b/spec/support/table_helpers/identifier.rb new file mode 100644 index 00000000000..155cabfb61c --- /dev/null +++ b/spec/support/table_helpers/identifier.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + module Identifier + extend self + + def to_identifier(name) + name = name.downcase + name.strip! + name.tr!(' \-', '_') + name.gsub!(/_+(?=\d)/, '') + name.to_sym + end + end +end diff --git a/spec/support/table_helpers/let_work_packages.rb b/spec/support/table_helpers/let_work_packages.rb new file mode 100644 index 00000000000..cf783b9297e --- /dev/null +++ b/spec/support/table_helpers/let_work_packages.rb @@ -0,0 +1,70 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + module LetWorkPackages + # Declare work packages and relations from a visual chart representation. + # + # It uses +create_table+ internally and is useful to have direct access + # to the created work packages. + # + # To see supported columns, see +TableHelpers::Column+. + # + # For instance: + # + # let_work_packages(<<~TABLE) + # hierarchy | work | + # parent | 1h | + # child | 2.5h | + # another one | | + # TABLE + # + # is equivalent to: + # + # let!(:_table) do + # create_table(table_representation) + # end + # let(:parent) do + # _table.work_package(:parent) + # end + # let(:child) do + # _table.work_package(:child) + # end + # let(:another_one) do + # _table.work_package(:another_one) + # end + def let_work_packages(table_representation) + let!(:_table) { create_table(table_representation) } + + table_data = TableData.for(table_representation) + table_data.work_package_identifiers.each do |identifier| + let(identifier) { _table.work_package(identifier) } + end + end + end +end diff --git a/spec/support/table_helpers/table.rb b/spec/support/table_helpers/table.rb new file mode 100644 index 00000000000..6ac342651c7 --- /dev/null +++ b/spec/support/table_helpers/table.rb @@ -0,0 +1,52 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + class Table + def initialize(work_packages_by_identifier) + @work_packages_by_identifier = work_packages_by_identifier + end + + def work_package(name) + name = normalize_name(name) + @work_packages_by_identifier[name] + end + + private + + def normalize_name(name) + symbolic_name = name.to_sym + return symbolic_name if @work_packages_by_identifier.has_key?(symbolic_name) + + spell_checker = DidYouMean::SpellChecker.new(dictionary: @work_packages_by_identifier.keys.map(&:to_s)) + suggestions = spell_checker.correct(name).map(&:inspect).join(' ') + did_you_mean = " Did you mean #{suggestions} instead?" if suggestions.present? + raise "No work package with name #{name.inspect} in _table.#{did_you_mean}" + end + end +end diff --git a/spec/support/table_helpers/table_data.rb b/spec/support/table_helpers/table_data.rb new file mode 100644 index 00000000000..c367758d216 --- /dev/null +++ b/spec/support/table_helpers/table_data.rb @@ -0,0 +1,121 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + # Contains work packages information from a table representation. + class TableData + extend Identifier + + attr_reader :work_packages_data + + def self.for(representation) + work_packages_data = TableParser.new.parse(representation) + TableData.new(work_packages_data) + end + + def self.from_work_packages(work_packages, columns) + attribute_names = columns.map(&:attribute) + work_packages_data = work_packages.map do |work_package| + attributes = attribute_names.to_h { [_1, work_package.read_attribute(_1)] } + attributes[:parent] &&= to_identifier(attributes[:parent].subject) + row = columns.to_h { [_1.title, nil] } + identifier = to_identifier(work_package.subject) + { + attributes:, + row:, + identifier: + } + end + TableData.new(work_packages_data) + end + + def initialize(work_packages_data) + @work_packages_data = work_packages_data + end + + def columns + headers.map do |header| + Column.for(header) + end + end + + def headers + work_packages_data.first[:row].keys + end + + def values_for_attribute(attribute) + work_packages_data.map do |work_package_data| + work_package_data.dig(:attributes, attribute) + end + end + + def work_package_identifiers + work_packages_data.pluck(:identifier) + end + + def create_work_packages + work_packages_by_identifier = Factory.new(self).create + Table.new(work_packages_by_identifier) + end + + class Factory + attr_reader :table_data, :work_packages_by_identifier + + def initialize(table_data) + @table_data = table_data + @work_packages_by_identifier = {} + end + + def create + table_data.work_package_identifiers.map do |identifier| + create_work_package(identifier) + end + work_packages_by_identifier + end + + def create_work_package(identifier) + @work_packages_by_identifier[identifier] ||= begin + attributes = work_package_attributes(identifier) + attributes[:parent] = lookup_parent(attributes[:parent]) + FactoryBot.create(:work_package, attributes) + end + end + + def lookup_parent(identifier) + if identifier + @work_packages_by_identifier[identifier] || create_work_package(identifier) + end + end + + def work_package_attributes(identifier) + data = table_data.work_packages_data.find { |wpa| wpa[:identifier] == identifier.to_sym } + data[:attributes] + end + end + end +end diff --git a/spec/support/table_helpers/table_parser.rb b/spec/support/table_helpers/table_parser.rb new file mode 100644 index 00000000000..39f8518bd02 --- /dev/null +++ b/spec/support/table_helpers/table_parser.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + class TableParser + def parse(representation) + headers, *rows = representation.split("\n") + headers = headers.split('|') + rows = rows.filter_map { |row| parse_row(row, headers) } + work_packages_data = rows.map.with_index do |row, index| + { + attributes: {}, + index:, + row: + } + end + headers.compact_blank.each do |header| + column = Column.for(header) + column.read_and_update_work_packages_data(work_packages_data) + end + work_packages_data + end + + private + + def parse_row(row, headers) + case row + when '', /^\s*#/ + # noop + else + values = row.split('|') + headers.zip(values).to_h.compact_blank + end + end + end +end diff --git a/spec/support/table_helpers/table_representer.rb b/spec/support/table_helpers/table_representer.rb new file mode 100644 index 00000000000..abb7332e8f7 --- /dev/null +++ b/spec/support/table_helpers/table_representer.rb @@ -0,0 +1,63 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module TableHelpers + class TableRepresenter + attr_reader :tables_data, :columns + + def initialize(tables_data:, columns:) + @tables_data = tables_data + @columns = columns + end + + # rubocop:disable Style/MultilineBlockChain + def render(table_data) + column_and_cell_sizes + .map do |column, cell_size| + header = column.title.ljust(cell_size) + cells = table_data.values_for_attribute(column.attribute).map { column.cell_format(_1, cell_size) } + [header, *cells] + end + .transpose + .map { |row| "| #{row.join(' | ')} |\n" } + .join + end + # rubocop:enable Style/MultilineBlockChain + + private + + def column_and_cell_sizes + @column_and_cell_sizes ||= + columns.index_with do |column| + values = tables_data.flat_map { _1.values_for_attribute(column.attribute) } + values_max_size = values.map { column.format(_1).size }.max + [column.title.size, values_max_size].max + end + end + end +end diff --git a/spec/support_spec/table_helpers/column_spec.rb b/spec/support_spec/table_helpers/column_spec.rb new file mode 100644 index 00000000000..2470f0a28e2 --- /dev/null +++ b/spec/support_spec/table_helpers/column_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +module TableHelpers::Column + RSpec.describe Generic do + subject(:column) { described_class.new(header: 'Some header') } + + describe '#format' do + it 'renders the value as string' do + expect(column.format('hello')).to eq 'hello' + expect(column.format(42)).to eq '42' + expect(column.format(3.5)).to eq '3.5' + expect(column.format(nil)).to eq '' + expect(column.format(true)).to eq 'true' + end + end + + describe '#cell_format' do + it 'renders the value on the left side of the cell' do + expect(column.cell_format('hello', 0)).to eq 'hello' + expect(column.cell_format('hello', 10)).to eq 'hello ' + expect(column.cell_format('hello', 20)).to eq 'hello ' + end + end + end + + RSpec.describe Duration do + subject(:column) { described_class.new(header: 'Duration in hours') } + + describe '#parse' do + it 'parses empty string as nil' do + expect(column.parse('')).to be_nil + end + end + + describe '#format' do + it 'renders the duration with a "h" suffix' do + expect(column.format(3.5)).to eq '3.5h' + end + + it 'renders the duration without the decimal part if the decimal part is 0' do + expect(column.format(3.0)).to eq '3h' + end + + it 'renders nothing if nil' do + expect(column.format(nil)).to eq '' + end + end + + describe '#cell_format' do + it 'renders the duration on the right side of the cell' do + expect(column.cell_format(3.5, 0)).to eq '3.5h' + expect(column.cell_format(3.5, 10)).to eq ' 3.5h' + expect(column.cell_format(3.5, 20)).to eq ' 3.5h' + end + end + end +end diff --git a/spec/support_spec/table_helpers/identifier_spec.rb b/spec/support_spec/table_helpers/identifier_spec.rb new file mode 100644 index 00000000000..59c2a9cccfa --- /dev/null +++ b/spec/support_spec/table_helpers/identifier_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +module TableHelpers + RSpec.describe Identifier do + shared_examples 'an identifier' do |name:, expected_identifier:| + it "converts #{name.inspect} to identifier #{expected_identifier.inspect}" do + expect(described_class.to_identifier(name)).to eq(expected_identifier) + end + end + + include_examples 'an identifier', name: 'Subject', expected_identifier: :subject + include_examples 'an identifier', name: 'Work package', expected_identifier: :work_package + include_examples 'an identifier', name: 'grand-child', expected_identifier: :grand_child + include_examples 'an identifier', name: 'Child 1', expected_identifier: :child1 + end +end diff --git a/spec/support_spec/table_helpers/table_data_spec.rb b/spec/support_spec/table_helpers/table_data_spec.rb new file mode 100644 index 00000000000..06808c812d4 --- /dev/null +++ b/spec/support_spec/table_helpers/table_data_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +module TableHelpers + RSpec.describe TableData do + describe '.for' do + it 'reads a table representation and stores its data' do + table = <<~TABLE + | subject | remaining work | + | work package | 3h | + | another one | | + TABLE + + table_data = described_class.for(table) + expect(table_data.work_packages_data.size).to eq(2) + expect(table_data.columns.size).to eq(2) + expect(table_data.headers).to eq([' subject ', ' remaining work ']) + expect(table_data.work_package_identifiers).to eq(%i[work_package another_one]) + end + end + + describe '.from_work_packages' do + it 'reads data from work packages according to the given columns' do + table = <<~TABLE + | subject | remaining work | + | work package | 3h | + | another one | | + TABLE + columns = described_class.for(table).columns + + work_package = build(:work_package, subject: 'work package', remaining_hours: 3) + another_one = build(:work_package, subject: 'another one') + + table_data = described_class.from_work_packages([work_package, another_one], columns) + expect(table_data.work_packages_data.size).to eq(2) + expect(table_data.columns.size).to eq(2) + expect(table_data.headers).to eq(['subject', 'remaining work']) + expect(table_data.work_package_identifiers).to eq(%i[work_package another_one]) + end + end + + describe '#values_for_attribute' do + it 'returns all the values of the work packages for the given attribute' do + table = <<~TABLE + | subject | remaining work | + | work package | 3h | + | another one | | + TABLE + + table_data = described_class.for(table) + expect(table_data.values_for_attribute(:remaining_hours)).to eq([3.0, nil]) + expect(table_data.values_for_attribute(:subject)).to eq(['work package', 'another one']) + end + end + end +end diff --git a/spec/support_spec/table_helpers/table_parser_spec.rb b/spec/support_spec/table_helpers/table_parser_spec.rb new file mode 100644 index 00000000000..5fd259dd252 --- /dev/null +++ b/spec/support_spec/table_helpers/table_parser_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +RSpec.describe TableHelpers::TableParser do + let(:parsed_data) { described_class.new.parse(table) } + + it 'ignores empty decorative columns' do + table = <<~TABLE + | subject | description | + | foo | bar | + TABLE + parsed_data = described_class.new.parse(table) + expect(parsed_data.dig(0, :attributes).keys).to eq(%i[subject description]) + end + + it 'normalizes column names to identifiers' do + table = <<~TABLE + | DeScRiPtIoN | + | value | + TABLE + parsed_data = described_class.new.parse(table) + expect(parsed_data.dig(0, :attributes).keys).to eq(%i[description]) + end + + it 'ignores comments and empty lines' do + table = <<~TABLE + | subject | + # this comment and the following empty line are ignored + + | value | + TABLE + parsed_data = described_class.new.parse(table) + expect(parsed_data.length).to eq(1) + end + + it 'raises a error if the header name is deprecated ' \ + '(for example "remaining hours" instead of "remaining work")' do + table = <<~TABLE + subject | remaining hours + wp | 4h + TABLE + expect { described_class.new.parse(table) } + .to raise_error(ArgumentError, 'Please use "remaining work" instead of "remaining hours"') + end + + describe 'subject column' do + let(:table) do + <<~TABLE + | subject | + | Work Package | + TABLE + end + + it 'sets the subject attribute' do + data = parsed_data.first + expect(data.dig(:attributes, :subject)).to eq('Work Package') + end + + it 'sets the identifier as the subject snake-cased' do + data = parsed_data.first + expect(data[:identifier]).to eq(:work_package) + end + end + + describe 'hierarchy column' do + let(:table) do + <<~TABLE + hierarchy + Parent + Child + Grand-Child + Child 2 + Child 3 + Another child + Root sibling + TABLE + end + + it 'sets the parent attribute by its identifier' do + attributes = parsed_data.flat_map { _1[:attributes] } + expect(attributes.pluck(:parent)).to eq([nil, :parent, :child, :parent, :parent, :child3, nil]) + end + + it 'sets the subject attribute' do + attributes = parsed_data.flat_map { _1[:attributes] } + expect(attributes.pluck(:subject)) + .to eq(['Parent', 'Child', 'Grand-Child', 'Child 2', 'Child 3', 'Another child', 'Root sibling']) + end + + it 'sets the identifier metadata as the subject snake-cased' do + expect(parsed_data.pluck(:identifier)) + .to eq(%i[parent child grand_child child2 child3 another_child root_sibling]) + end + end + + describe 'remaining work column' do + let(:table) do + <<~TABLE + subject | remaining work + wp | 9h + TABLE + end + + it 'sets the derived remaining work attribute' do + expect(parsed_data.first[:attributes]).to include(remaining_hours: 9) + end + end + + describe 'derived remaining work column' do + let(:table) do + <<~TABLE + subject | derived remaining work + wp | 9h + TABLE + end + + it 'sets the derived remaining work attribute' do + expect(parsed_data.first[:attributes]).to include(derived_remaining_hours: 9) + end + end +end diff --git a/spec/support_spec/table_helpers/table_representer_spec.rb b/spec/support_spec/table_helpers/table_representer_spec.rb new file mode 100644 index 00000000000..f684ca48f42 --- /dev/null +++ b/spec/support_spec/table_helpers/table_representer_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +module TableHelpers + RSpec.describe TableRepresenter do + let(:table) do + <<~TABLE + | subject | remaining work | derived remaining work | + | Work Package | 1.5h | 9h | + TABLE + end + let(:table_data) { TableData.for(table) } + let(:tables_data) { [table_data] } + + subject(:representer) { described_class.new(tables_data:, columns:) } + + context 'when using a second table for the size' do + let(:twin_table) do + <<~TABLE + | subject | + | A quite long work package name | + TABLE + end + let(:twin_table_data) { TableData.for(twin_table) } + + let(:tables_data) { [table_data, twin_table_data] } + let(:columns) { [Column.for('subject')] } + + it 'adapts the column sizes to fit the largest value of both tables ' \ + 'so that they can be compared and diffed' do + expect(representer.render(table_data)).to eq <<~TABLE + | subject | + | Work Package | + TABLE + expect(representer.render(twin_table_data)).to eq <<~TABLE + | subject | + | A quite long work package name | + TABLE + end + end + + describe 'subject column' do + let(:columns) { [Column.for('subject')] } + + it 'is rendered as text' do + expect(representer.render(table_data)).to eq <<~TABLE + | subject | + | Work Package | + TABLE + end + end + + describe 'remaining work column' do + let(:columns) { [Column.for('remaining work')] } + + it 'is rendered as a duration' do + expect(representer.render(table_data)).to eq <<~TABLE + | remaining work | + | 1.5h | + TABLE + end + end + + describe 'derived remaining work column' do + let(:columns) { [Column.for('derived remaining work')] } + + it 'sets the derived remaining work attribute' do + expect(representer.render(table_data)).to eq <<~TABLE + | derived remaining work | + | 9h | + TABLE + end + end + end +end