Hello community, here is the log from the commit of package yast2-installation for openSUSE:Factory checked in at 2015-07-05 17:52:00 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/yast2-installation (Old) and /work/SRC/openSUSE:Factory/.yast2-installation.new (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Package is "yast2-installation" Changes: -------- --- /work/SRC/openSUSE:Factory/yast2-installation/yast2-installation.changes 2015-06-03 08:27:46.000000000 +0200 +++ /work/SRC/openSUSE:Factory/.yast2-installation.new/yast2-installation.changes 2015-07-05 17:52:01.000000000 +0200 @@ -1,0 +2,29 @@ +Wed Jul 1 10:46:50 CEST 2015 - locilka@suse.com + +- Fixed handling user request to change an installation proposal + (bsc#936448) +- 3.1.148 + +------------------------------------------------------------------- +Mon Jun 29 13:11:57 UTC 2015 - lslezak@suse.cz + +- fixed menu button label in the proposal (bsc#936427) +- 3.1.147 + +------------------------------------------------------------------- +Mon Jun 29 08:41:17 UTC 2015 - jreidinger@suse.com + +- add ability to hide export button (fate#315161) +- 3.1.146 + +------------------------------------------------------------------- +Wed Jun 17 09:29:09 CEST 2015 - locilka@suse.com + +- Implemented triggers for installation proposal (FATE#317488). + Any *_proposal client can define 'trigger' in 'MakeProposal' + that defines in which circumstances it should be called again + after all proposals have been called, e.g., if partitioning or + software selection changes. +- 3.1.145 + +------------------------------------------------------------------- Old: ---- yast2-installation-3.1.144.tar.bz2 New: ---- yast2-installation-3.1.148.tar.bz2 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ yast2-installation.spec ++++++ --- /var/tmp/diff_new_pack.MtreG3/_old 2015-07-05 17:52:02.000000000 +0200 +++ /var/tmp/diff_new_pack.MtreG3/_new 2015-07-05 17:52:02.000000000 +0200 @@ -17,7 +17,7 @@ Name: yast2-installation -Version: 3.1.144 +Version: 3.1.148 Release: 0 BuildRoot: %{_tmppath}/%{name}-%{version}-build ++++++ yast2-installation-3.1.144.tar.bz2 -> yast2-installation-3.1.148.tar.bz2 ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/yast2-installation-3.1.144/doc/proposal_api.md new/yast2-installation-3.1.148/doc/proposal_api.md --- old/yast2-installation-3.1.144/doc/proposal_api.md 2015-06-02 10:54:08.000000000 +0200 +++ new/yast2-installation-3.1.148/doc/proposal_api.md 2015-07-01 10:59:37.000000000 +0200 @@ -88,6 +88,25 @@ * _string_ `help` (optional) Helptext for this module which appears in the standard dialog help (particular helps for modules sorted by presentation order). +* _map_ `trigger` defines circumstances when the proposal should be called again at the end. + For intance, when partitioning or software selection changes. + Mandatory keys of the trigger are: + + * _map_ `expect` containing _string_ `class` and _string_ `method` that will be called and its result compared with `value` + * _any_ `value` expected value, if the evaluated code does not match the `value`, proposal will be called again + + Example: + + { + "trigger" => { + "expect" => { + "class" => "Yast::Packages", + "method" => "CountSizeToBeDownloaded" + } + "value" => 88883333 + } + } + ### AskUser Run an interactive workflow - let user decide upon values he might want to change. May contain one single dialog, a sequence of dialogs or one master dialog with diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/yast2-installation-3.1.144/package/yast2-installation.changes new/yast2-installation-3.1.148/package/yast2-installation.changes --- old/yast2-installation-3.1.144/package/yast2-installation.changes 2015-06-02 10:54:08.000000000 +0200 +++ new/yast2-installation-3.1.148/package/yast2-installation.changes 2015-07-01 10:59:37.000000000 +0200 @@ -1,4 +1,33 @@ ------------------------------------------------------------------- +Wed Jul 1 10:46:50 CEST 2015 - locilka@suse.com + +- Fixed handling user request to change an installation proposal + (bsc#936448) +- 3.1.148 + +------------------------------------------------------------------- +Mon Jun 29 13:11:57 UTC 2015 - lslezak@suse.cz + +- fixed menu button label in the proposal (bsc#936427) +- 3.1.147 + +------------------------------------------------------------------- +Mon Jun 29 08:41:17 UTC 2015 - jreidinger@suse.com + +- add ability to hide export button (fate#315161) +- 3.1.146 + +------------------------------------------------------------------- +Wed Jun 17 09:29:09 CEST 2015 - locilka@suse.com + +- Implemented triggers for installation proposal (FATE#317488). + Any *_proposal client can define 'trigger' in 'MakeProposal' + that defines in which circumstances it should be called again + after all proposals have been called, e.g., if partitioning or + software selection changes. +- 3.1.145 + +------------------------------------------------------------------- Tue Jun 2 08:41:03 UTC 2015 - jreidinger@suse.com - fix crash in Upgrade when creating post upgrade snapshot diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/yast2-installation-3.1.144/package/yast2-installation.spec new/yast2-installation-3.1.148/package/yast2-installation.spec --- old/yast2-installation-3.1.144/package/yast2-installation.spec 2015-06-02 10:54:08.000000000 +0200 +++ new/yast2-installation-3.1.148/package/yast2-installation.spec 2015-07-01 10:59:37.000000000 +0200 @@ -17,7 +17,7 @@ Name: yast2-installation -Version: 3.1.144 +Version: 3.1.148 Release: 0 BuildRoot: %{_tmppath}/%{name}-%{version}-build diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/yast2-installation-3.1.144/src/lib/installation/proposal_runner.rb new/yast2-installation-3.1.148/src/lib/installation/proposal_runner.rb --- old/yast2-installation-3.1.144/src/lib/installation/proposal_runner.rb 2015-06-02 10:54:08.000000000 +0200 +++ new/yast2-installation-3.1.148/src/lib/installation/proposal_runner.rb 2015-07-01 10:59:37.000000000 +0200 @@ -74,6 +74,9 @@ return :auto end + args = (Yast::WFM.Args || []).first || {} + @hide_export = args["hide_export"] + log.info "Installation step #2" @proposal_mode = Yast::GetInstArgs.proposal @@ -653,8 +656,10 @@ change_point = ReplacePoint( Id(:rep_menu), # menu button - MenuButton(Id(:menu_dummy), _("&Yast::Change..."), [Item(Id(:dummy), "")]) + MenuButton(Id(:menu_dummy), _("&Change..."), [Item(Id(:dummy), "")]) ) + elsif @hide_export + change_point = Empty() else change_point = PushButton( Id(:export_config), @@ -706,7 +711,7 @@ Label( if Yast::UI.TextMode() _( - "Click a headline to make changes or use the \"Yast::Change...\" menu below." + "Click a headline to make changes or use the \"Change...\" menu below." ) else _( @@ -745,7 +750,7 @@ # now build the menu button menu_list = @submodules_presentation.each_with_object([]) do |submod, menu| - descr = @store.descriptions[submod] || {} + descr = @store.description_for(submod) || {} next if descr.empty? id = descr["id"] @@ -769,8 +774,8 @@ end # menu button item - menu_list << Item(Id(:reset_to_defaults), _("&Reset to defaults")) << - Item(Id(:export_config), _("&Export Configuration")) + menu_list << Item(Id(:reset_to_defaults), _("&Reset to defaults")) + menu_list << Item(Id(:export_config), _("&Export Configuration")) unless @hide_export # menu button Yast::UI.ReplaceWidget( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/yast2-installation-3.1.144/src/lib/installation/proposal_store.rb new/yast2-installation-3.1.148/src/lib/installation/proposal_store.rb --- old/yast2-installation-3.1.144/src/lib/installation/proposal_store.rb 2015-06-02 10:54:08.000000000 +0200 +++ new/yast2-installation-3.1.148/src/lib/installation/proposal_store.rb 2015-07-01 10:59:37.000000000 +0200 @@ -29,7 +29,12 @@ include Yast::Logger include Yast::I18n - # @ param[String] proposal_mode one of initial, service, network, hardware, + # How many times to maximally (re)run the proposal while some proposal clients + # try to re-trigger their run again, number includes their initial run + # and resets before each proposal loop starts + MAX_LOOPS_IN_PROPOSAL = 8 + + # @param [String] proposal_mode one of initial, service, network, hardware, # uml, ... or anything else def initialize(proposal_mode) Yast.import "Mode" @@ -131,8 +136,13 @@ @proposal_names.map!(&:first) # first element is name of client - # FIXME: add filter to only installed clients - @proposal_names + missing_proposals = @proposal_names.reject { |proposal| Yast::WFM::ClientExists(proposal) } + unless missing_proposals.empty? + log.warn "These proposals are missing on system: #{missing_proposals}" + end + + # Filter missing proposals out + @proposal_names -= missing_proposals end # returns single list of modules presentation order or list of tabs with list of modules @@ -145,73 +155,85 @@ # Makes proposal for all proposal clients. # @param callback Called after each client/part, to report progress. Gets # part name and part result as arguments - def make_proposals(force_reset: false, language_changed: false, callback: Proc.new) - @link2submod = {} + def make_proposals(force_reset: false, language_changed: false, callback: proc {}) + clear_proposals - proposal_names.each do |submod| - proposal_map = make_proposal(submod, force_reset: force_reset, - language_changed: language_changed) - - callback.call(submod, proposal_map) - - # update link map - (proposal_map["links"] || []).each do |link| - @link2submod[link] = submod - end + # At first run, all clients will be called + call_proposals = proposal_names + log.info "Proposals to call: #{call_proposals}" + + loop do + call_proposals.each do |client| + description_map = make_proposal(client, force_reset: force_reset, + language_changed: language_changed, callback: callback) - if proposal_map["language_changed"] - @descriptions = nil # invalid descriptions cache - return make_proposals(force_reset: force_reset, language_changed: true) + break unless parse_description_map(client, description_map, force_reset, callback) end - break if proposal_map["warning_level"] == :fatal - end - end - - # Calls all clients/parts to retrieve the description - # @return [Hash{String => Hash}] map client/part names to hashes with keys - # "id", "menu_title" "rich_text_title" http://www.rubydoc.info/github/yast/yast-yast2/Installation/ProposalClient:d... - def descriptions - return @descriptions if @descriptions + # Second and next runs: only triggered clients will be called + call_proposals = proposal_names.select { |client| should_be_called_again?(client) } - missing_no = 1 - @id_mapping = {} - @descriptions = proposal_names.each_with_object({}) do |client, res| - description = Yast::WFM.CallFunction(client, ["Description", {}]) - if !description["id"] - log.warn "proposal client #{client} missing key 'id' in #{description}" + break if call_proposals.empty? + log.info "These proposals want to be called again: #{call_proposals}" - description["id"] = "module_#{missing_no}" - missing_no += 1 + unless should_run_proposals_again?(call_proposals) + log.warn "Too many loops in proposal, exiting" + break end + end - @id_mapping[description["id"]] = client + log.info "Making proposals have finished" + end - res[client] = description + # Calls a given client/part to retrieve their description + # @return [Hash] with keys "id", "menu_title" "rich_text_title" + # @see http://www.rubydoc.info/github/yast/yast-yast2/Installation/ProposalClient:d... + def description_for(client) + @descriptions ||= {} + return @descriptions[client] if @descriptions.key?(client) + + description = Yast::WFM.CallFunction(client, ["Description", {}]) + + unless description.key?("id") + log.warn "proposal client #{client} is missing key 'id' in #{description}" + @missing_no ||= 1 + description["id"] = "module_#{@missing_no}" + @missing_no += 1 end + + @descriptions[client] = description + end + + # Returns all currently cached client descriptions + # + # @return [Hash] with descriptions + def descriptions + @descriptions ||= {} end + # Returns ID for given client + # # @return [String] an id provided by the description API def id_for(client) - descriptions[client]["id"] + description_for(client).fetch("id", client) end + # Returns UI title for given client + # + # @param [String] client + # @return [String] a title provided by the description API def title_for(client) - descriptions[client]["rich_text_title"] || - descriptions[client]["rich_text_raw_title"] || + description = description_for(client) + + description["rich_text_title"] || + description["rich_text_raw_title"] || client end - # Calls `ask_user`, to change a setting interactively (if link is the + # Calls client('AskUser'), to change a setting interactively (if link is the # heading for the part) or noninteractively (if it is a "shortcut") def handle_link(link) - client = @id_mapping[link] - client ||= @link2submod[link] - - if !client - log.error "unknown link #{link}. Broken proposal client?" - return nil - end + client = client_for_link(link) data = { "has_next" => false, @@ -221,8 +243,143 @@ Yast::WFM.CallFunction(client, ["AskUser", data]) end + # Returns client name that handles the given link returned by UI, + # raises exception if link is unknown. + # Link can be either the client ID or a shortcut link from proposal text. + # + # @param [String] link ID + # @return [String] client name + def client_for_link(link) + raise "There are no client proposals known, call 'client(MakeProposal)' first" if @proposals.nil? + + matching_client = @proposals.find do |_client, proposal| + link == proposal["id"] || proposal.fetch("links", []).include?(link) + end + + raise "Unknown user request #{link}. Broken proposal client?" if matching_client.nil? + + matching_client.first + end + private + # Evaluates the given description map, and handles all the events + # by returning whether to continue in the current proposal loop + # Also stores proposals for later use + # + # @return [Boolean] whether to continue with iteration over proposals + def parse_description_map(client, description_map, force_reset, callback) + raise "Invalid proposal from client #{client}" if description_map.nil? + + if description_map["warning_level"] == :fatal + log.error "There is an error in the proposal" + return false + end + + if description_map["language_changed"] + log.info "Language changed, reseting proposal" + # Invalidate all descriptions at once, they will be lazy-loaded again with new translations + invalidate_description + make_proposals(force_reset: force_reset, language_changed: true, callback: callback) + return false + end + + description_map["id"] = id_for(client) + + @proposals ||= {} + @proposals[client] = description_map + + true + end + + def clear_proposals + @proposals_run_counter = {} + @proposals = {} + end + + # Updates internal counter that holds information how many times + # has been each proposal called during the current make_proposals run + def update_proposals_counter(proposals) + @proposals_run_counter ||= {} + + proposals.each do |proposal| + @proposals_run_counter[proposal] ||= 0 + @proposals_run_counter[proposal] += 1 + end + end + + # Finds out whether we can call given proposals again during + # the current make_proposals run + def should_run_proposals_again?(proposals) + update_proposals_counter(proposals) + + log.info "Proposal counters: #{@proposals_run_counter}" + @proposals_run_counter.values.max < MAX_LOOPS_IN_PROPOSAL + end + + # Returns whether given trigger definition is correct + # e.g., all mandatory parts are there + # + # @param [Hash] trigger definition + # @rturn [Boolean] whether it is correct + def valid_trigger?(trigger_def) + trigger_def.key?("expect") && + trigger_def["expect"].is_a?(Hash) && + trigger_def["expect"].key?("class") && + trigger_def["expect"]["class"].is_a?(String) && + trigger_def["expect"].key?("method") && + trigger_def["expect"]["method"].is_a?(String) && + trigger_def.key?("value") + end + + # Returns whether given client should be called again during 'this' + # proposal run according to triggers in proposals + # + # @param [String] client name + # @return [Boolean] whether it should be called + def should_be_called_again?(client) + @proposals ||= {} + return false unless @proposals.fetch(client, {}).key?("trigger") + + trigger = @proposals[client]["trigger"] + + raise "Incorrect definition of 'trigger': #{trigger.inspect} \n" \ + "both [Hash] 'expect', including keys [Symbol] 'class' and [Symbol] 'method', \n" \ + "and [Any] 'value' must be set" unless valid_trigger?(trigger) + + expectation_class = trigger["expect"]["class"] + expectation_method = trigger["expect"]["method"] + expectation_value = trigger["value"] + + log.info "Calling #{expectation_class}.send(#{expectation_method.inspect})" + + begin + value = Object.const_get(expectation_class).send(expectation_method) + rescue StandardError, ScriptError => error + raise "Checking the trigger expectations for #{client} have failed:\n#{error}" + end + + if value == expectation_value + log.info "Proposal client #{client}: returned value matches expectation #{value.inspect}" + return false + else + log.info "Proposal client #{client}: returned value #{value.inspect} " \ + "does not match expected value #{expectation_value.inspect}" + return true + end + end + + # Invalidates proposal description coming from a given client + # + # @param [String] client or nil for all descriptions + def invalidate_description(client = nil) + if client.nil? + @descriptions = {} + else + @descriptions.delete(client) + end + end + def properties @proposal_properties ||= Yast::ProductControl.getProposalProperties( Yast::Stage.stage, @@ -231,7 +388,7 @@ ) end - def make_proposal(client, force_reset: false, language_changed: false) + def make_proposal(client, force_reset: false, language_changed: false, callback: proc {}) proposal = Yast::WFM.CallFunction( client, [ @@ -245,6 +402,9 @@ log.debug "#{client} MakeProposal() returns #{proposal}" + raise "Callback is not a block: #{callback.class}" unless callback.is_a? Proc + callback.call(client, proposal) + proposal end @@ -352,9 +512,8 @@ modules_order = modules_order[current_tab] modules_order.each_with_object("") do |client, text| - if descriptions[client] && !descriptions[client]["help"].to_s.empty? - text << descriptions[client]["help"] - end + description = description_for(client) + text << description["help"] if description["help"] end else "" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/yast2-installation-3.1.144/test/proposal_store_test.rb new/yast2-installation-3.1.148/test/proposal_store_test.rb --- old/yast2-installation-3.1.144/test/proposal_store_test.rb 2015-06-02 10:54:08.000000000 +0200 +++ new/yast2-installation-3.1.148/test/proposal_store_test.rb 2015-07-01 10:59:37.000000000 +0200 @@ -5,6 +5,7 @@ require "installation/proposal_store" Yast.import "ProductControl" +Yast.import "Installation" describe ::Installation::ProposalStore do subject { ::Installation::ProposalStore.new "initial" } @@ -88,7 +89,13 @@ end describe "#proposal_names" do + before do + allow(Yast::WFM).to receive(:ClientExists).and_return(true) + end + it "returns array with string names of clients" do + allow(Yast::WFM).to receive(:ClientExists).with(/test3/).and_return(false) + allow(Yast::ProductControl).to receive(:getProposals) .and_return([ ["test1"], @@ -98,7 +105,7 @@ expect(subject.proposal_names).to include("test1") expect(subject.proposal_names).to include("test2") - expect(subject.proposal_names).to include("test3") + expect(subject.proposal_names).not_to include("test3") end it "use same order as in control file to preserve evaluation order" do @@ -183,4 +190,306 @@ end end end + + let(:proposal_names) { ["proposal_a", "proposal_b", "proposal_c"] } + + let(:proposal_a) do + { + "rich_text_title" => "Proposal A", + "menu_title" => "&Proposal A", + "id" => "proposal_a" + } + end + + let(:proposal_a_desc) do + { + "preformatted_proposal" => "Values proposed for A", + "links" => ["proposal_a-link_1", "proposal_a-link_2"] + } + end + + let(:proposal_a_expected_val) { "/" } + + let(:proposal_a_desc_with_trigger) do + { + "preformatted_proposal" => "Values proposed for A", + "links" => ["proposal_a-link_1", "proposal_a-link_2"], + "trigger" => { + "expect" => { + "class" => "Yast::Installation", + "method" => "destdir" + }, + "value" => proposal_a_expected_val + } + } + end + + let(:proposal_b) do + { + "rich_text_title" => "Proposal B", + "menu_title" => "&Proposal B", + "id" => "proposal_b" + } + end + + let(:proposal_b_desc) do + { + "preformatted_proposal" => "Values proposed for B" + } + end + + let(:proposal_b_desc_with_language_change) do + { + "preformatted_proposal" => "Values proposed for B", + "language_changed" => true + } + end + + let(:proposal_b_desc_with_fatal_error) do + { + "preformatted_proposal" => "Values proposed for A", + "warning_level" => :fatal, + "warning" => "some fatal error" + } + end + + let(:proposal_c) do + { + "rich_text_title" => "Proposal C", + "menu_title" => "&Proposal C" + } + end + + let(:proposal_c_desc) do + { + "preformatted_proposal" => "Values proposed for C" + } + end + + let(:proposal_c_desc_with_incorrect_trigger) do + { + "preformatted_proposal" => "Values proposed for C", + "trigger" => { + # 'expect' must be a string that is evaluated later + "expect" => 333, + "value" => "anything" + } + } + end + + let(:proposal_c_desc_with_exception) do + { + "preformatted_proposal" => "Values proposed for C", + "trigger" => { + # 'expect' must be a string that is evaluated later + "expect" => { + "class" => "Erroneous", + "method" => "big_mistake" + }, + "value" => 22 + } + } + end + + describe "#make_proposals" do + before do + allow(subject).to receive(:proposal_names).and_return(proposal_names) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_a", ["Description", anything]).and_return(proposal_a) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["Description", anything]).and_return(proposal_b) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_c", ["Description", anything]).and_return(proposal_c) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_a", ["MakeProposal", anything]).and_return(proposal_a_desc) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["MakeProposal", anything]).and_return(proposal_b_desc) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_c", ["MakeProposal", anything]).and_return(proposal_c_desc) + end + + context "when all proposals return correct data" do + it "for each proposal client, calls given callback and creates new proposal" do + @callback = 0 + callback = proc { @callback += 1 } + + expect { subject.make_proposals(callback: callback) }.not_to raise_exception + expect(@callback).to eq(proposal_names.size) + end + end + + context "when some proposal returns invalid data (e.g. crashes)" do + it "raises an exception" do + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", anything).and_return(nil) + + expect { subject.make_proposals }.to raise_exception(/Invalid proposal from client/) + end + end + + context "when given callback is not a block" do + it "raises an exception" do + expect { subject.make_proposals(callback: 4) }.to raise_exception(/Callback is not a block/) + end + end + + context "when returned proposal contains a 'trigger' section" do + it "for each proposal client, creates new proposal and calls the client while trigger evaluates to true" do + allow(Yast::WFM).to receive(:CallFunction).with("proposal_a", anything).and_return(proposal_a_desc_with_trigger) + + # Mock evaluation of the trigger + allow(Yast::Installation).to receive(:destdir).and_return("/x", "/y", proposal_a_expected_val) + + # 1. initial call 2. (...) via trigger + expect(subject).to receive(:make_proposal).with("proposal_a", anything).exactly(3).times.and_call_original + expect(subject).to receive(:make_proposal).with("proposal_b", anything).exactly(1).times.and_call_original + expect(subject).to receive(:make_proposal).with("proposal_c", anything).exactly(1).times.and_call_original + + subject.make_proposals + end + end + + context "when returned proposal triggers changing a language" do + it "calls all proposals again with language_changed: true" do + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["MakeProposal", anything]).and_return(proposal_b_desc_with_language_change, proposal_b_desc) + + # Call proposals till the one that changes the language + expect(subject).to receive(:make_proposal).with("proposal_a", hash_including(language_changed: false)).once.and_call_original + expect(subject).to receive(:make_proposal).with("proposal_b", hash_including(language_changed: false)).once.and_call_original + + # Call all again with language_changed: true + expect(subject).to receive(:make_proposal).with("proposal_a", hash_including(language_changed: true)).once.and_call_original + expect(subject).to receive(:make_proposal).with("proposal_b", hash_including(language_changed: true)).once.and_call_original + expect(subject).to receive(:make_proposal).with("proposal_c", hash_including(language_changed: true)).once.and_call_original + + subject.make_proposals + end + end + + context "when returned proposal contains a fatal error" do + it "calls all proposals till fatal error is received, then it stops proceeding immediately" do + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["MakeProposal", anything]).and_return(proposal_b_desc_with_fatal_error) + + expect(subject).to receive(:make_proposal).with("proposal_a", anything).once.and_call_original + expect(subject).to receive(:make_proposal).with("proposal_b", anything).once.and_call_original + # Proposal C is never called, as it goes after proposal B + expect(subject).not_to receive(:make_proposal).with("proposal_c", anything) + + subject.make_proposals + end + end + + context "when trigger from proposal is incorrectly set" do + it "raises an exception" do + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["MakeProposal", anything]).and_return(proposal_c_desc_with_incorrect_trigger) + + expect { subject.make_proposals }.to raise_error(/Incorrect definition/) + end + end + + context "when trigger from proposal raises an exception" do + it "raises an exception" do + allow(Yast::WFM).to receive(:CallFunction).with("proposal_c", ["MakeProposal", anything]).and_return(proposal_c_desc_with_exception) + + expect { subject.make_proposals }.to raise_error(/Checking the trigger expectations for proposal_c have failed/) + end + end + + context "When any proposal client wants to retrigger its run more than MAX_LOOPS_IN_PROPOSAL times" do + it "stops iterating over proposals immediately" do + allow(subject).to receive(:should_be_called_again?).with(/proposal_(a|b)/).and_return(false) + # Proposal C wants to be called again and again + allow(subject).to receive(:should_be_called_again?).with("proposal_c").and_return(true) + + expect(subject).to receive(:make_proposal).with(/proposal_(a|b)/, anything).twice.and_call_original + # Number of calls including the initial one + expect(subject).to receive(:make_proposal).with("proposal_c", anything).exactly(8).times.and_call_original + + subject.make_proposals + end + end + end + + let(:client_description) do + { + "rich_text_title" => "Software", + "menu_title" => "&Software", + "id" => "software" + } + end + + let(:client_name) { "software_proposal" } + + describe "#description_for" do + it "returns description for a given client" do + expect(Yast::WFM).to receive(:CallFunction).with(client_name, ["Description", {}]).and_return(client_description).once + + desc1 = subject.description_for(client_name) + # description should be cached + desc2 = subject.description_for(client_name) + + expect(desc1["id"]).to eq("software") + expect(desc2["id"]).to eq("software") + end + end + + describe "#id_for" do + it "returns id for a given client" do + allow(subject).to receive(:description_for).with(client_name).and_return(client_description) + + expect(subject.id_for(client_name)).to eq(client_description["id"]) + end + end + + describe "#title_for" do + it "returns title for a given client" do + allow(subject).to receive(:description_for).with(client_name).and_return(client_description) + + expect(subject.title_for(client_name)).to eq(client_description["rich_text_title"]) + end + end + + describe "#handle_link" do + before do + allow(subject).to receive(:proposal_names).and_return(proposal_names) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_a", ["Description", anything]).and_return(proposal_a) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["Description", anything]).and_return(proposal_b) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_c", ["Description", anything]).and_return(proposal_c) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_a", ["MakeProposal", anything]).and_return(proposal_a_desc) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_b", ["MakeProposal", anything]).and_return(proposal_b_desc) + allow(Yast::WFM).to receive(:CallFunction).with("proposal_c", ["MakeProposal", anything]).and_return(proposal_c_desc) + end + + context "when client('MakeProposal') has not been called before" do + it "raises an exception" do + expect { subject.handle_link("proposal_a-link_2") }.to raise_error(/no client proposals known/) + end + end + + context "when no client matches the given link" do + it "raises an exception" do + # Cache some proposals first + subject.make_proposals + + expect { subject.handle_link("unknown_link") }.to raise_error(/Unknown user request/) + end + end + + context "when client('MakeProposal') has been called before" do + context "when handling link from returned proposal" do + it "calls a respective client(AskUser) and returns its result" do + # Proposals need to be cached first + subject.make_proposals + + expect(Yast::WFM).to receive(:CallFunction).with("proposal_a", + ["AskUser", { "has_next" => false, "chosen_id" => "proposal_a-link_2" }]).and_return(:next) + expect(subject.handle_link("proposal_a-link_2")).to eq(:next) + end + end + + context "when handling link == client id from Description" do + it "calls a respective client(AskUser) and returns its result" do + # Proposals need to be cached first + subject.make_proposals + + expect(Yast::WFM).to receive(:CallFunction).with("proposal_a", + ["AskUser", { "has_next" => false, "chosen_id" => "proposal_a" }]).and_return(:next) + expect(subject.handle_link("proposal_a")).to eq(:next) + end + end + end + end end