From e6688dfdc3fff3962f56c033b807b1b11f886a65 Mon Sep 17 00:00:00 2001 From: gangelo Date: Fri, 26 Jan 2024 12:18:14 -0500 Subject: [PATCH] Project model specs and dsu project delete subcommad wip --- .rubocop.yml | 6 + lib/dsu/models/project.rb | 68 +++-- .../presenters/project/delete_presenter.rb | 93 +++++++ lib/dsu/subcommands/project.rb | 10 +- lib/dsu/views/project/delete.rb | 94 +++++++ lib/locales/en/active_record.yml | 2 + spec/dsu/crud/json_file_spec.rb | 2 +- spec/dsu/models/project_spec.rb | 233 ++++++++++++++++-- .../entry_group/importer_service_spec.rb | 2 - spec/dsu/views/project/create_spec.rb | 2 - 10 files changed, 462 insertions(+), 50 deletions(-) create mode 100644 lib/dsu/presenters/project/delete_presenter.rb create mode 100644 lib/dsu/views/project/delete.rb diff --git a/.rubocop.yml b/.rubocop.yml index 04bd1ed9..f96d78dd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -196,3 +196,9 @@ RSpec/NotToNot: SupportedStyles: - to_not - not_to + +RSpec/MultipleExpectations: + Max: 3 + +RSpec/MultipleMemoizedHelpers: + Max: 6 diff --git a/lib/dsu/models/project.rb b/lib/dsu/models/project.rb index cd3a5ef0..54d406e9 100644 --- a/lib/dsu/models/project.rb +++ b/lib/dsu/models/project.rb @@ -53,6 +53,10 @@ def ==(other) end alias eql? == + def can_delete? + self.class.can_delete?(project_name: project_name) + end + def create self.class.create(project_name: project_name, description: description) end @@ -74,16 +78,16 @@ def default! end def default_project? - project_name == self.class.default_project_name + self.class.default_project?(project_name: project_name) end - # def delete - # self.class.delete(project_name: project_name) - # end + def delete + self.class.delete(project_name: project_name) + end - # def delete! - # self.class.delete!(time: time) - # end + def delete! + self.class.delete!(project_name: project_name) + end def hash [project_name, description, version].map(&:hash).hash @@ -132,6 +136,21 @@ def all # project_metadata.any? # end + def can_delete?(project_name:) + exist?(project_name: project_name) && + # Cannot delete the last project. + count > 1 && + # Do not allow the project to be deleted if it + # is currently the default project. + # The user needs to change to another default + # project before they can delete this project. + !default_project?(project_name: project_name) + end + + def count + project_metadata.count + end + def create(project_name:, description: nil, options: {}) Models::Project.new(project_name: project_name, description: description, options: options).tap do |project| project.validate! @@ -166,17 +185,32 @@ def default_project find(project_name: default_project_name) end - # def delete(project_name:) - # # TODO: read all entry groups and delete them - # # TODO: delete the project folder - # # superclass.delete(file_path: project_folder_for(project_name: project_name)) - # end + def default_project?(project_name:) + project_name == default_project_name + end - # def delete!(project_name:) - # # TODO: read all entry groups and delete them - # # TODO: delete the project folder - # # superclass.delete!(file_path: project_folder_for(project_name: project_name)) - # end + def delete(project_name:) + return false unless can_delete?(project_name: project_name) + + project_folder = project_folder_for(project_name: project_name) + FileUtils.rm_rf(project_folder) + + true + end + + def delete!(project_name:) + unless exist?(project_name: project_name) + raise I18n.t('models.project.errors.does_not_exist', project_name: project_name) + end + + raise I18n.t('models.project.errors.delete_only_project', project_name: project_name) unless count > 1 + + if default_project?(project_name: project_name) + raise I18n.t('models.project.errors.delete_default_project', project_name: project_name) + end + + delete(project_name: project_name) + end def find(project_name:) unless project_folder_exist?(project_name: project_name) diff --git a/lib/dsu/presenters/project/delete_presenter.rb b/lib/dsu/presenters/project/delete_presenter.rb new file mode 100644 index 00000000..944cf476 --- /dev/null +++ b/lib/dsu/presenters/project/delete_presenter.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative '../../models/project' +require_relative '../base_presenter_ex' + +module Dsu + module Presenters + module Project + class UsePresenter < BasePresenterEx + attr_writer :project_name_or_number + + def initialize(project_name_or_number:, options: {}) + super(options: options) + + @project_name_or_number = project_name_or_number + end + + def respond(response:) + return false unless response + + project.delete! if project&.present? + end + + def project_name_or_number + return project_name if delete_by_project_name? + return project_number if delete_by_project_number? + + Models::Project.default_project_name + end + + def project_description + return unless project&.present? + + project.description + end + + def project_does_not_exist? + !project&.exist? + end + + def project_errors + return [] unless project_errors? + + project.errors.full_messages + end + + def delete_by_project_name? + !delete_by_project_number? && !delete_by_project_default? + end + + def delete_by_project_number? + /\A\d+\z/.match?(@project_name_or_number.to_s) + end + + def delete_by_project_default? + @project_name_or_number.blank? + end + + private + + attr_reader :options + + def project + return @project if defined?(@project) + + @project = if delete_by_project_name? && Dsu::Models::Project.project_initialized?(project_name: project_name) + Dsu::Models::Project.find(project_name: project_name) + elsif delete_by_project_number? + Dsu::Models::Project.find_by_number(project_number: project_number) + elsif delete_by_project_default? + Dsu::Models::Project.default_project + end + end + + def project_errors? + project&.invalid? + end + + def project_name + return unless delete_by_project_name? + + @project_name_or_number + end + + def project_number + return -1 unless delete_by_project_number? + + @project_name_or_number.to_i + end + end + end + end +end diff --git a/lib/dsu/subcommands/project.rb b/lib/dsu/subcommands/project.rb index e7539b39..b844dc5a 100644 --- a/lib/dsu/subcommands/project.rb +++ b/lib/dsu/subcommands/project.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative '../presenters/project/create_presenter' +require_relative '../presenters/project/delete_presenter' require_relative '../presenters/project/list_presenter' require_relative '../presenters/project/use_presenter' require_relative '../views/project/create' @@ -33,11 +34,12 @@ def create desc I18n.t('subcommands.project.delete.desc'), I18n.t('subcommands.project.delete.usage') long_desc I18n.t('subcommands.project.delete.long_desc') - option :project_name, type: :string, required: true, aliases: '-n', banner: 'PROJECT_NAME' option :prompts, type: :hash, default: {}, hide: true, aliases: '-p' - def delete - # Views::Import.new(presenter: all_presenter(import_file_path: options[:import_file], - # options: options)).render + def delete(project_name_or_number = nil) + options = configuration.to_h.merge(self.options).with_indifferent_access + presenter = Presenters::Project::DeletePresenter.new(project_name_or_number: project_name_or_number, + options: options) + Views::Project::Delete.new(presenter: presenter, options: options).render end desc I18n.t('subcommands.project.list.desc'), I18n.t('subcommands.project.list.usage') diff --git a/lib/dsu/views/project/delete.rb b/lib/dsu/views/project/delete.rb new file mode 100644 index 00000000..8e19cd33 --- /dev/null +++ b/lib/dsu/views/project/delete.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative '../../env' +require_relative '../../models/color_theme' +require_relative '../../support/ask' +require_relative '../../support/color_themable' + +module Dsu + module Views + module Project + class Delete + include Support::Ask + include Support::ColorThemable + + attr_reader :presenter + + def initialize(presenter:, options: {}) + @presenter = presenter + @options = options&.dup || {} + @color_theme = Models::ColorTheme.find(theme_name: theme_name) + end + + def render + return display_project_does_not_exists if presenter.project_does_not_exist? + return display_project_errors if presenter.project_errors.any? + + response = display_project_delete_prompt + if presenter.respond response: response + display_deleted_project_message + else + display_delete_project_cancelled_message + end + rescue StandardError => e + puts apply_theme(e.message, theme_color: color_theme.error) + puts apply_theme(e.backtrace_locations.join("\n"), theme_color: color_theme.error) if Dsu.env.local? + end + + private + + attr_reader :color_theme, :options + + def display_project_delete_prompt + response = ask_while(prompt_with_options(prompt: delete_prompt, + options: delete_prompt_options), options: options) do |input| + message = I18n.t('information.input.try_again', options: delete_prompt_options.join(',')) + puts apply_theme(message, theme_color: color_theme.info) unless delete_prompt_options.include?(input) + delete_prompt_options.include?(input) + end + response == delete_prompt_options.first + end + + def display_delete_project_cancelled_message + message = I18n.t('subcommands.project.messages.cancelled', project_name: presenter.project_name_or_number) + puts apply_theme(message, theme_color: color_theme.info) + end + + def display_project_errors + errors = presenter.project_errors.join("\n") + puts apply_theme(errors, theme_color: color_theme.error) + end + + def display_project_does_not_exists + message = if presenter.delete_by_project_number? + I18n.t('subcommands.project.messages.number_does_not_exist', + project_number: presenter.project_name_or_number) + else + I18n.t('subcommands.project.messages.does_not_exist', + project_name: presenter.project_name_or_number) + end + puts apply_theme(message, theme_color: color_theme.error) + end + + def display_deleted_project_message + message = I18n.t('subcommands.project.delete.messages.using_project', + project_name: presenter.project_name_or_number) + puts apply_theme(message, theme_color: color_theme.success) + end + + def delete_prompt + I18n.t('subcommands.project.delete.prompts.delete_confirm', + project_name: presenter.project_name_or_number, description: presenter.project_description) + end + + def delete_prompt_options + I18n.t('subcommands.project.delete.prompts.delete_options') + end + + def theme_name + @theme_name ||= options.fetch(:theme_name, Models::Configuration.new.theme_name) + end + end + end + end +end diff --git a/lib/locales/en/active_record.yml b/lib/locales/en/active_record.yml index d548a615..fda3376c 100644 --- a/lib/locales/en/active_record.yml +++ b/lib/locales/en/active_record.yml @@ -4,6 +4,8 @@ en: errors: already_exists: "Project '%{project_name}' already exists." does_not_exist: "Project '%{project_name}' does not exist." + delete_default_project: "Project '%{project_name}' is the default project. Change to a different default project before deleting this project." + delete_only_project: "Project '%{project_name}' is the only project and cannot be deleted." project_file_not_exist: "Project file '%{project_file}' does not exist." activerecord: errors: diff --git a/spec/dsu/crud/json_file_spec.rb b/spec/dsu/crud/json_file_spec.rb index 944aa2f2..95d8c104 100644 --- a/spec/dsu/crud/json_file_spec.rb +++ b/spec/dsu/crud/json_file_spec.rb @@ -140,7 +140,7 @@ def to_h end end - context 'when the validation fails' do # rubocop:disable RSpec/MultipleMemoizedHelpers + context 'when the validation fails' do subject(:json_file_write) { json_file.write! } let(:file_data) { nil } diff --git a/spec/dsu/models/project_spec.rb b/spec/dsu/models/project_spec.rb index d281ae6b..33dfa71d 100644 --- a/spec/dsu/models/project_spec.rb +++ b/spec/dsu/models/project_spec.rb @@ -5,33 +5,11 @@ described_class.new(project_name: project_name, description: description, version: version, options: options) end - shared_examples 'the projects are different' do - it 'returns false' do - expect(project == different_project).to be false - end - end - - shared_examples 'the default project is the default project' do # rubocop:disable RSpec/MultipleMemoizedHelpers - let(:default_project_name) { Dsu::Models::Configuration.new.default_project } - let(:expected_default_project) { described_class.default_project } - - it 'exists and is initialized' do - expect(expected_default_project.project_initialized?).to be true - end - - it 'is the default project' do - expect(expected_default_project.default_project?).to be true - end - - it 'has the default project name' do - expect(expected_default_project.project_name).to eq default_project_name - end - end - let(:project_name) { 'Test' } - let(:description) { 'Test project' } + let(:description) { nil } let(:version) { nil } let(:options) { {} } + let(:default_project) { described_class.default_project } describe '#initialize' do context 'when the arguments are valid' do @@ -111,6 +89,12 @@ end describe '#==' do + shared_examples 'the projects are different' do + it 'returns false' do + expect(project == different_project).to be false + end + end + context 'when the projects are equal' do it 'returns true' do expect(project == project.clone).to be true @@ -156,6 +140,63 @@ end end + describe 'can_delete?' do + shared_examples 'the project can be deleted' do + before do + project.save! + end + + it 'is not the only project' do + expect(described_class.count).to be > 1 + end + + it 'returns true' do + expect(project.can_delete?).to be true + end + end + + shared_examples "the project can't be deleted" do + it 'returns false' do + expect(project.can_delete?).to be false + end + end + + # rubocop:disable RSpec/RepeatedExampleGroupBody + context 'when the project exists' do + it_behaves_like 'the project can be deleted' + end + + context 'when there is more than one project' do + it_behaves_like 'the project can be deleted' + end + + context 'when the project is not the default project' do + it_behaves_like 'the project can be deleted' + end + # rubocop:enable RSpec/RepeatedExampleGroupBody + + context 'when the project does not exist' do + it_behaves_like "the project can't be deleted" + end + + context 'when there is only one project' do + before do + project_folder = default_project.project_folder + FileUtils.rm_rf(project_folder) if project_folder.start_with?(temp_folder) + + project.save! + end + + it_behaves_like "the project can't be deleted" + end + + context 'when the project is the default project' do + subject(:project) { default_project } + + it_behaves_like "the project can't be deleted" + end + end + describe '#create' do context 'when the project does not exist' do it do @@ -281,6 +322,133 @@ end end + describe '#delete' do + context 'when the project exists' do + before do + project.save! + end + + it 'exists' do + expect(project.exist?).to be true + end + + it 'returns true' do + expect(project.delete).to be true + end + + it 'deletes the project' do + project.delete + expect(project.exist?).to be false + end + end + + context 'when the project does not exist' do + it 'does not exist' do + expect(project.exist?).to be false + end + + it 'returns false' do + expect(project.delete).to be false + end + end + + context 'when trying to delete the only project' do + it 'exists' do + expect(default_project.exist?).to be true + end + + it 'is the default project' do + expect(default_project.default_project?).to be true + end + + it 'returns false' do + expect(default_project.delete).to be false + end + + it 'does not delete the project' do + default_project.delete + expect(default_project.exist?).to be true + end + end + + context 'when trying to delete the default project' do + before do + project.save! + project.default! + end + + it 'is the default project' do + expect(project.default_project?).to be true + end + + it 'returns false' do + expect(project.delete).to be false + end + + it 'does not delete the project' do + project.delete + expect(project.exist?).to be true + end + end + end + + describe '#delete!' do + shared_examples "the project can't be deleted" do + it 'cannot delete the project' do + expect { project.delete! }.to raise_error(expected_error) + end + end + + context 'when deleting a project that exists' do + before do + project.save! + end + + it 'exists' do + expect(project.exist?).to be true + end + + it 'returns true' do + expect(project.delete!).to be true + end + + it 'deletes the project' do + project.delete! + expect(project.exist?).to be false + end + end + + context 'when trying to delete a project that does not exist' do + let(:expected_error) { /'#{project.project_name}' does not exist/ } + + it_behaves_like "the project can't be deleted" + end + + context 'when trying to delete the only project' do + before do + project_folder = default_project.project_folder + FileUtils.rm_rf(project_folder) if project_folder.start_with?(temp_folder) + + project.save! + end + + let(:expected_error) { /'#{project.project_name}' is the only project/ } + + it_behaves_like "the project can't be deleted" + end + + context 'when trying to delete the default project' do + before do + project.save! + project.default! + end + + let(:expected_error) { /'#{project.project_name}' is the default project/ } + + it_behaves_like "the project can't be deleted" + end + end + describe '#description' do context 'when the description is blank' do it "returns ' project' capitalized" do @@ -356,6 +524,23 @@ # end describe 'class methods' do + shared_examples 'the default project is the default project' do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:default_project_name) { Dsu::Models::Configuration.new.default_project } + let(:expected_default_project) { described_class.default_project } + + it 'exists and is initialized' do + expect(expected_default_project.project_initialized?).to be true + end + + it 'is the default project' do + expect(expected_default_project.default_project?).to be true + end + + it 'has the default project name' do + expect(expected_default_project.project_name).to eq default_project_name + end + end + describe '.all' do context 'when there are is only the default project' do it 'returns an empty Array' do diff --git a/spec/dsu/services/entry_group/importer_service_spec.rb b/spec/dsu/services/entry_group/importer_service_spec.rb index 17dae192..32b173b2 100644 --- a/spec/dsu/services/entry_group/importer_service_spec.rb +++ b/spec/dsu/services/entry_group/importer_service_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# rubocop:disable RSpec/MultipleMemoizedHelpers RSpec.describe Dsu::Services::EntryGroup::ImporterService do subject(:service) { described_class.new(import_entry_groups: import_entry_groups, options: options) } @@ -190,4 +189,3 @@ end end end -# rubocop:enable RSpec/MultipleMemoizedHelpers diff --git a/spec/dsu/views/project/create_spec.rb b/spec/dsu/views/project/create_spec.rb index 709d5101..c596ca5e 100644 --- a/spec/dsu/views/project/create_spec.rb +++ b/spec/dsu/views/project/create_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# rubocop:disable RSpec/MultipleMemoizedHelpers RSpec.describe Dsu::Views::Project::Create do subject(:create_view) do described_class.new(presenter: presenter, options: options) @@ -115,4 +114,3 @@ end end end -# rubocop:enable RSpec/MultipleMemoizedHelpers