From 57005432e2c8cded51f84d7ac7a21463c28e6290 Mon Sep 17 00:00:00 2001 From: lukemelia Date: Tue, 10 Oct 2006 07:17:54 +0000 Subject: [PATCH] Converted "done" property of a project into a status with states of 'active', 'hidden' & 'completed'. Fixes #389. Added acts_as_state_machine plugin and made Project use it to implement this change. git-svn-id: http://www.rousette.org.uk/svn/tracks-repos/trunk@324 a4c988fc-2ded-0310-b66e-134b36920a42 --- tracks/app/models/project.rb | 27 ++- tracks/app/views/project/_project.rhtml | 4 +- tracks/app/views/project/_project_form.rhtml | 12 +- .../app/views/project/_project_listing.rhtml | 4 +- tracks/app/views/project/show.rhtml | 54 +++-- .../app/views/shared/_add_new_item_form.rhtml | 2 +- .../app/views/shared/_new_action_form.rhtml | 2 +- tracks/app/views/shared/sidebar.rhtml | 4 +- .../014_convert_project_to_state_machine.rb | 28 +++ tracks/db/schema.rb | 8 +- tracks/public/stylesheets/standard.css | 6 + tracks/test/fixtures/projects.yml | 6 +- .../test/integration/project_xml_api_test.rb | 2 +- tracks/test/unit/project_test.rb | 23 ++ .../plugins/acts_as_state_machine/CHANGELOG | 7 + .../plugins/acts_as_state_machine/MIT-LICENSE | 20 ++ .../plugins/acts_as_state_machine/README | 33 +++ .../plugins/acts_as_state_machine/Rakefile | 28 +++ .../vendor/plugins/acts_as_state_machine/TODO | 11 + .../plugins/acts_as_state_machine/init.rb | 5 + .../lib/acts_as_state_machine.rb | 222 ++++++++++++++++++ .../test/acts_as_state_machine_test.rb | 174 ++++++++++++++ .../acts_as_state_machine/test/database.yml | 18 ++ .../test/fixtures/conversation.rb | 50 ++++ .../test/fixtures/conversations.yml | 11 + .../test/fixtures/person.rb | 2 + .../acts_as_state_machine/test/schema.rb | 11 + .../acts_as_state_machine/test/test_helper.rb | 38 +++ 28 files changed, 765 insertions(+), 47 deletions(-) create mode 100644 tracks/db/migrate/014_convert_project_to_state_machine.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/CHANGELOG create mode 100644 tracks/vendor/plugins/acts_as_state_machine/MIT-LICENSE create mode 100644 tracks/vendor/plugins/acts_as_state_machine/README create mode 100644 tracks/vendor/plugins/acts_as_state_machine/Rakefile create mode 100644 tracks/vendor/plugins/acts_as_state_machine/TODO create mode 100644 tracks/vendor/plugins/acts_as_state_machine/init.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/acts_as_state_machine_test.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/database.yml create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversation.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversations.yml create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/fixtures/person.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/schema.rb create mode 100644 tracks/vendor/plugins/acts_as_state_machine/test/test_helper.rb diff --git a/tracks/app/models/project.rb b/tracks/app/models/project.rb index 3ea6e46e..41f05e52 100644 --- a/tracks/app/models/project.rb +++ b/tracks/app/models/project.rb @@ -1,11 +1,7 @@ class Project < ActiveRecord::Base - has_many :todos, :dependent => true has_many :notes, :dependent => true, :order => "created_at DESC" belongs_to :user - acts_as_list :scope => :user - - attr_protected :user # Project name must not be empty # and must be less than 255 bytes @@ -13,7 +9,28 @@ class Project < ActiveRecord::Base validates_length_of :name, :maximum => 255, :message => "project name must be less than 256 characters" validates_uniqueness_of :name, :message => "already exists", :scope =>"user_id" validates_format_of :name, :with => /^[^\/]*$/i, :message => "cannot contain the slash ('/') character" - + + acts_as_list :scope => :user + acts_as_state_machine :initial => :active, :column => 'state' + + state :active + state :hidden + state :completed + + event :activate do + transitions :to => :active, :from => [:hidden, :complete] + end + + event :hide do + transitions :to => :hidden, :from => [:active, :complete] + end + + event :complete do + transitions :to => :completed, :from => [:active, :hidden] + end + + attr_protected :user + def description_present? attribute_present?("description") end diff --git a/tracks/app/views/project/_project.rhtml b/tracks/app/views/project/_project.rhtml index c1e41be9..a4fd8864 100644 --- a/tracks/app/views/project/_project.rhtml +++ b/tracks/app/views/project/_project.rhtml @@ -11,8 +11,10 @@
<%= sanitize(@project.description) %>
<% end -%> - <% if @project.done? -%> + <% if @project.completed? -%>

Project has been marked as completed

+ <% elsif @project.completed? -%> +

Project has been marked as hidden

<% end -%>
diff --git a/tracks/app/views/project/_project_form.rhtml b/tracks/app/views/project/_project_form.rhtml index 64ec4eca..d6df8245 100644 --- a/tracks/app/views/project/_project_form.rhtml +++ b/tracks/app/views/project/_project_form.rhtml @@ -3,14 +3,18 @@ %> - <%= text_field 'project', 'name', :class => 'project-name' %> + <%= text_field :project, 'name', :class => 'project-name' %> - <%= text_area 'project', 'description', "cols" => 30, "rows" => 4, :class => 'project-description' %> + <%= text_area :project, 'description', "cols" => 30, "rows" => 4, :class => 'project-description' %> - - <%= check_box 'project', 'done', :class => 'project-done' %> + + + <% ['active', 'hidden', 'completed'].each do | state | %> + <%= radio_button(:project, 'state', state, {:class => 'project-done'}) %> <%= state.titlecase %> + <% end %> + <% @project = nil %> \ No newline at end of file diff --git a/tracks/app/views/project/_project_listing.rhtml b/tracks/app/views/project/_project_listing.rhtml index fe857197..62b748a3 100644 --- a/tracks/app/views/project/_project_listing.rhtml +++ b/tracks/app/views/project/_project_listing.rhtml @@ -9,8 +9,10 @@ <%= link_to( sanitize("#{project.name}"), :action => "show", :name => urlize(project.name) ) %><%= " (" + count_undone_todos(project,"actions") + ")" %>
- <% if project.done? -%> + <% if project.completed? -%> COMPLETED + <% elsif project.hidden? -%> + HIDDEN <% else -%> ACTIVE <% end -%> diff --git a/tracks/app/views/project/show.rhtml b/tracks/app/views/project/show.rhtml index 2c5d9cce..6ea82550 100644 --- a/tracks/app/views/project/show.rhtml +++ b/tracks/app/views/project/show.rhtml @@ -13,36 +13,42 @@
-

Notes

-
- <%= render :partial => "shared/empty", + +

Notes

+
+ <%= render :partial => "shared/empty", :locals => { :message => "Currently there are no notes attached to this project"} %> -
- <%= render :partial => "note/notes_summary", :collection => @notes %> +
+ <%= render :partial => "note/notes_summary", :collection => @notes %>
+
-<% if @project.done? -%> - <%= button_to "Mark project as uncompleted", {:action => "toggle_project_done", :id => @project.id} %>
-<% else -%> - <%= button_to "Mark project as completed", {:action => "toggle_project_done", :id => @project.id} %>
-<% end -%> + -<%= link_to_function( "Add a note", "Element.toggle('new-note'); Form.focusFirstElement('form-new-note');") %> - - -
diff --git a/tracks/app/views/shared/_add_new_item_form.rhtml b/tracks/app/views/shared/_add_new_item_form.rhtml index 42c7f663..d387d4a0 100644 --- a/tracks/app/views/shared/_add_new_item_form.rhtml +++ b/tracks/app/views/shared/_add_new_item_form.rhtml @@ -52,7 +52,7 @@

- <%= select( "todo", "project_id", @projects.reject{|x| x.done? }.collect {|p| [p.name, p.id] }, + <%= select( "todo", "project_id", @projects.select{|x| x.active? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %>
diff --git a/tracks/app/views/shared/_new_action_form.rhtml b/tracks/app/views/shared/_new_action_form.rhtml index 6bbd7777..843dc643 100644 --- a/tracks/app/views/shared/_new_action_form.rhtml +++ b/tracks/app/views/shared/_new_action_form.rhtml @@ -46,7 +46,7 @@ - <%= select( "todo", "project_id", @projects.reject{|x| x.done? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %> + <%= select( "todo", "project_id", @projects.select{|x| x.active? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %> diff --git a/tracks/app/views/shared/sidebar.rhtml b/tracks/app/views/shared/sidebar.rhtml index 1ffbf286..2f10c582 100644 --- a/tracks/app/views/shared/sidebar.rhtml +++ b/tracks/app/views/shared/sidebar.rhtml @@ -1,6 +1,6 @@

Active Projects:

    - <% for project in @projects.reject{|p| p.done? } -%> + <% for project in @projects.select{|p| p.active? } -%> <% end -%> @@ -9,7 +9,7 @@ <% if @user.preference.show_completed_projects_in_sidebar %>

    Completed Projects:

      - <% for project in @projects.reject{|p| !p.done? } -%> + <% for project in @projects.select{|p| p.completed? } -%> <% end -%> diff --git a/tracks/db/migrate/014_convert_project_to_state_machine.rb b/tracks/db/migrate/014_convert_project_to_state_machine.rb new file mode 100644 index 00000000..0ccde4bb --- /dev/null +++ b/tracks/db/migrate/014_convert_project_to_state_machine.rb @@ -0,0 +1,28 @@ +class ConvertProjectToStateMachine < ActiveRecord::Migration + + class Project < ActiveRecord::Base; end + + def self.up + ActiveRecord::Base.transaction do + add_column :projects, :state, :string, :limit => 20, :default => "active", :null => false + @projects = Project.find(:all) + @projects.each do |project| + project.state = project.done ? 'completed' : 'active' + project.save + end + remove_column :projects, :done + end + end + + def self.down + ActiveRecord::Base.transaction do + add_column :projects, :done, :integer, :limit => 4, :default => 0, :null => false + @projects = Project.find(:all) + @projects.each do |project| + project.done = project.state == 'completed' + project.save + end + remove_column :projects, :state + end + end +end diff --git a/tracks/db/schema.rb b/tracks/db/schema.rb index bea37478..c3805237 100644 --- a/tracks/db/schema.rb +++ b/tracks/db/schema.rb @@ -2,7 +2,7 @@ # migrations feature of ActiveRecord to incrementally modify your database, and # then regenerate this schema definition. -ActiveRecord::Schema.define(:version => 13) do +ActiveRecord::Schema.define(:version => 14) do create_table "contexts", :force => true do |t| t.column "name", :string, :default => "", :null => false @@ -24,20 +24,20 @@ ActiveRecord::Schema.define(:version => 13) do t.column "date_format", :string, :limit => 40, :default => "%d/%m/%Y", :null => false t.column "week_starts", :integer, :default => 0, :null => false t.column "show_number_completed", :integer, :default => 5, :null => false - t.column "staleness_starts", :integer, :default => 14, :null => false + t.column "staleness_starts", :integer, :default => 7, :null => false t.column "show_completed_projects_in_sidebar", :boolean, :default => true, :null => false t.column "show_hidden_contexts_in_sidebar", :boolean, :default => true, :null => false t.column "due_style", :integer, :default => 0, :null => false - t.column "admin_email", :string, :default => "", :null => false + t.column "admin_email", :string, :default => "butshesagirl@rousette.org.uk", :null => false t.column "refresh", :integer, :default => 0, :null => false end create_table "projects", :force => true do |t| t.column "name", :string, :default => "", :null => false t.column "position", :integer, :default => 0, :null => false - t.column "done", :integer, :limit => 4, :default => 0, :null => false t.column "user_id", :integer, :default => 0, :null => false t.column "description", :text + t.column "state", :string, :limit => 20, :default => "active", :null => false end create_table "sessions", :force => true do |t| diff --git a/tracks/public/stylesheets/standard.css b/tracks/public/stylesheets/standard.css index cf981cac..7ec15cd2 100644 --- a/tracks/public/stylesheets/standard.css +++ b/tracks/public/stylesheets/standard.css @@ -298,6 +298,12 @@ div.note_footer a, div.note_footer a:hover { background-color: transparent; } +div.add_note_link { + float: right; + } +div#status > div { + padding: 10px; + } a.footer_link {color: #cc3334; font-style: normal;} a.footer_link:hover {color: #fff; background-color: #cc3334 !important;} diff --git a/tracks/test/fixtures/projects.yml b/tracks/test/fixtures/projects.yml index b2547e98..5276c6df 100644 --- a/tracks/test/fixtures/projects.yml +++ b/tracks/test/fixtures/projects.yml @@ -5,7 +5,7 @@ timemachine: name: Build a working time machine description: '' position: 1 - done: false + state: 'active' user_id: 1 moremoney: @@ -13,7 +13,7 @@ moremoney: name: Make more money than Billy Gates description: '' position: 2 - done: false + state: 'active' user_id: 1 gardenclean: @@ -21,5 +21,5 @@ gardenclean: name: Evict dinosaurs from the garden description: '' position: 3 - done: false + state: 'active' user_id: 1 diff --git a/tracks/test/integration/project_xml_api_test.rb b/tracks/test/integration/project_xml_api_test.rb index 4f71d687..c1689653 100644 --- a/tracks/test/integration/project_xml_api_test.rb +++ b/tracks/test/integration/project_xml_api_test.rb @@ -53,7 +53,7 @@ class ProjectControllerXmlApiTest < ActionController::IntegrationTest def test_creates_new_project initial_count = Project.count authenticated_post_xml_to_project_create - assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n\n #{@@project_name}\n 0\n [0-9]+\n \n 1\n\n$| + assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n\n #{@@project_name}\n [0-9]+\n \n 1\n active\n$| assert_equal initial_count + 1, Project.count project1 = Project.find_by_name(@@project_name) assert_not_nil project1, "expected project '#{@@project_name}' to be created" diff --git a/tracks/test/unit/project_test.rb b/tracks/test/unit/project_test.rb index 7d07c449..b04e5795 100644 --- a/tracks/test/unit/project_test.rb +++ b/tracks/test/unit/project_test.rb @@ -38,5 +38,28 @@ class ProjectTest < Test::Unit::TestCase assert_equal 1, newproj.errors.count assert_equal "cannot contain the slash ('/') character", newproj.errors.on(:name) end + + def test_project_initial_state_is_active + assert_equal :active, @timemachine.current_state + assert @timemachine.active? + end + + def test_hide_project + @timemachine.hide! + assert_equal :hidden, @timemachine.current_state + assert @timemachine.hidden? + end + + def test_activate_project + @timemachine.activate! + assert_equal :active, @timemachine.current_state + assert @timemachine.active? + end + + def test_complete_project + @timemachine.complete! + assert_equal :completed, @timemachine.current_state + assert @timemachine.completed? + end end diff --git a/tracks/vendor/plugins/acts_as_state_machine/CHANGELOG b/tracks/vendor/plugins/acts_as_state_machine/CHANGELOG new file mode 100644 index 00000000..d694511b --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/CHANGELOG @@ -0,0 +1,7 @@ +* 2.0 * (2006-01-20 15:26:28 -0500) + Enter / Exit actions + Transition guards + Guards and actions can be a symbol pointing to a method or a Proc + +* 1.0 * (2006-01-15 12:16:55 -0500) + Initial Release \ No newline at end of file diff --git a/tracks/vendor/plugins/acts_as_state_machine/MIT-LICENSE b/tracks/vendor/plugins/acts_as_state_machine/MIT-LICENSE new file mode 100644 index 00000000..3189ba6b --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2006 Scott Barron + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tracks/vendor/plugins/acts_as_state_machine/README b/tracks/vendor/plugins/acts_as_state_machine/README new file mode 100644 index 00000000..2bbcced6 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/README @@ -0,0 +1,33 @@ += Acts As State Machine + +This act gives an Active Record model the ability to act as a finite state +machine (FSM). + +Acquire via subversion at: + +http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk + +If prompted, use the user/pass anonymous/anonymous. + +== Example + + class Order < ActiveRecord::Base + acts_as_state_machine :initial => :opened + + state :opened + state :closed, :enter => Proc.new {|o| Mailer.send_notice(o)} + state :returned + + event :close do + transitions :to => :closed, :from => :opened + end + + event :return do + transitions :to => :returned, :from => :closed + end + end + + o = Order.create + o.close! # notice is sent by mailer + o.return! + diff --git a/tracks/vendor/plugins/acts_as_state_machine/Rakefile b/tracks/vendor/plugins/acts_as_state_machine/Rakefile new file mode 100644 index 00000000..101fdde9 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/Rakefile @@ -0,0 +1,28 @@ +require 'rake' +require 'rake/testtask' +require 'rake/rdoctask' + +desc 'Default: run unit tests.' +task :default => [:clean_db, :test] + +desc 'Remove the stale db file' +task :clean_db do + `rm -f #{File.dirname(__FILE__)}/test/state_machine.sqlite.db` +end + +desc 'Test the acts as state machine plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +desc 'Generate documentation for the acts as state machine plugin.' +Rake::RDocTask.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'Acts As State Machine' + rdoc.options << '--line-numbers --inline-source' + rdoc.rdoc_files.include('README') + rdoc.rdoc_files.include('TODO') + rdoc.rdoc_files.include('lib/**/*.rb') +end diff --git a/tracks/vendor/plugins/acts_as_state_machine/TODO b/tracks/vendor/plugins/acts_as_state_machine/TODO new file mode 100644 index 00000000..8d5d7063 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/TODO @@ -0,0 +1,11 @@ +* Currently invalid events are ignored, create an option so that they can be + ignored or raise an exception. + +* Query for a list of possible next states. + +* Make listing states optional since they can be inferred from the events. + Only required to list a state if you want to define a transition block for it. + +* Real transition actions + +* Default states diff --git a/tracks/vendor/plugins/acts_as_state_machine/init.rb b/tracks/vendor/plugins/acts_as_state_machine/init.rb new file mode 100644 index 00000000..9ae95872 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/init.rb @@ -0,0 +1,5 @@ +require 'acts_as_state_machine' + +ActiveRecord::Base.class_eval do + include RailsStudio::Acts::StateMachine +end diff --git a/tracks/vendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb b/tracks/vendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb new file mode 100644 index 00000000..c5b4c790 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb @@ -0,0 +1,222 @@ +module RailsStudio #:nodoc: + module Acts #:nodoc: + module StateMachine #:nodoc: + class InvalidState < Exception #:nodoc: + end + class NoInitialState < Exception #:nodoc: + end + + def self.included(base) #:nodoc: + base.extend ActMacro + end + + module SupportingClasses + class StateTransition + attr_reader :from, :to + def initialize(from, to, guard=nil) + @from, @to, @guard = from, to, guard + end + + def guard(obj) + @guard ? obj.send(:run_transition_action, @guard) : true + end + + def ==(obj) + @from == obj.from && @to == obj.to + end + end + + class TransitionCollector + attr_reader :opts + def transitions(opts) + (@opts ||= []) << opts + end + end + end + + module ActMacro + # Configuration options are + # + # * +column+ - specifies the column name to use for keeping the state (default: state) + # * +initial+ - specifies an initial state for newly created objects (required) + def acts_as_state_machine(opts) + self.extend(ClassMethods) + raise NoInitialState unless opts[:initial] + + write_inheritable_attribute :states, {} + write_inheritable_attribute :initial_state, opts[:initial] + write_inheritable_attribute :transition_table, {} + write_inheritable_attribute :state_column, opts[:column] || 'state' + + class_inheritable_reader :initial_state + class_inheritable_reader :state_column + class_inheritable_reader :transition_table + + class_eval "include RailsStudio::Acts::StateMachine::InstanceMethods" + + before_create :set_initial_state + end + end + + module InstanceMethods + def set_initial_state #:nodoc: + write_attribute self.class.state_column, self.class.initial_state.to_s + end + + # Returns the current state the object is in, as a Ruby symbol. + def current_state + self.send(self.class.state_column).to_sym + end + + # Returns what the next state for a given event would be, as a Ruby symbol. + def next_state_for_event(event) + ns = next_states_for_event(event) + ns.empty? ? nil : ns.first.to + end + + def next_states_for_event(event) + self.class.read_inheritable_attribute(:transition_table)[event.to_sym].select do |s| + s.from == current_state + end + end + + def run_transition_action(action) + if action.kind_of?(Symbol) + self.method(action).call + else + action.call(self) + end + end + private :run_transition_action + end + + module ClassMethods + # Returns an array of all known states. + def states + read_inheritable_attribute(:states).keys + end + + # Define an event. This takes a block which describes all valid transitions + # for this event. + # + # Example: + # + # class Order < ActiveRecord::Base + # acts_as_state_machine :initial => :open + # + # state :open + # state :closed + # + # event :close_order do + # transitions :to => :closed, :from => :open + # end + # end + # + # +transitions+ takes a hash where :to is the state to transition + # to and :from is a state (or Array of states) from which this + # event can be fired. + # + # This creates an instance method used for firing the event. The method + # created is the name of the event followed by an exclamation point (!). + # Example: order.close_order!. + def event(event, &block) + class_eval <<-EOV + def #{event.to_s}! + next_states = next_states_for_event(:#{event.to_s}) + next_states.each do |ns| + if ns.guard(self) + loopback = current_state == ns.to + exitact = self.class.read_inheritable_attribute(:states)[current_state][:exit] + enteract = self.class.read_inheritable_attribute(:states)[ns.to][:enter] + + run_transition_action(enteract) if enteract && !loopback + + self.update_attribute(self.class.state_column, ns.to.to_s) + + run_transition_action(exitact) if exitact && !loopback + break + end + end + end + EOV + + tt = read_inheritable_attribute(:transition_table) + tt[event.to_sym] ||= [] + + if block_given? + t = SupportingClasses::TransitionCollector.new + t.instance_eval(&block) + trannys = t.opts + trannys.each do |tranny| + Array(tranny[:from]).each do |s| + tt[event.to_sym] << SupportingClasses::StateTransition.new(s.to_sym, tranny[:to], tranny[:guard]) + end + end + end + end + + def transitions(opts) #:nodoc: + opts + end + + # Define a state of the system. +state+ can take an optional Proc object + # which will be executed every time the system transitions into that + # state. The proc will be passed the current object. + # + # Example: + # + # class Order < ActiveRecord::Base + # acts_as_state_machine :initial => :open + # + # state :open + # state :closed, Proc.new { |o| Mailer.send_notice(o) } + # end + def state(state, opts={}) + read_inheritable_attribute(:states)[state.to_sym] = opts + + class_eval <<-EOS + def #{state.to_s}? + current_state == :#{state.to_s} + end + EOS + end + + # Wraps ActiveRecord::Base.find to conveniently find all records in + # a given state. Options: + # + # * +number+ - This is just :first or :all from ActiveRecord + # * +state+ - The state to find + # * +args+ - The rest of the args are passed down to ActiveRecord +find+ + def find_in_state(number, state, *args) + raise InvalidState unless states.include?(state) + + options = args.last.is_a?(Hash) ? args.pop : {} + if options[:conditions] + options[:conditions].first << " AND #{self.state_column} = ?" + options[:conditions] << state.to_s + else + options[:conditions] = ["#{self.state_column} = ?", state.to_s] + end + self.find(number, options) + end + + # Wraps ActiveRecord::Base.count to conveniently count all records in + # a given state. Options: + # + # * +state+ - The state to find + # * +args+ - The rest of the args are passed down to ActiveRecord +find+ + def count_in_state(state, conditions=nil) + raise InvalidState unless states.include?(state) + + if conditions + conditions.first << " AND #{self.state_column} = ?" + conditions << state.to_s + else + conditions = ["#{self.state_column} = ?", state.to_s] + end + self.count(conditions) + end + end + end + end +end \ No newline at end of file diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/acts_as_state_machine_test.rb b/tracks/vendor/plugins/acts_as_state_machine/test/acts_as_state_machine_test.rb new file mode 100644 index 00000000..60dd7b17 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/acts_as_state_machine_test.rb @@ -0,0 +1,174 @@ +require File.dirname(__FILE__) + '/test_helper' + +include RailsStudio::Acts::StateMachine + +class ActsAsStateMachineTest < Test::Unit::TestCase + fixtures :conversations + + def test_no_initial_value_raises_exception + assert_raise(NoInitialState) { + Person.acts_as_state_machine({}) + } + end + + def test_initial_state_value + assert_equal :needs_attention, Conversation.initial_state + end + + def test_column_was_set + assert_equal 'state_machine', Conversation.state_column + end + + def test_initial_state + c = Conversation.create + assert_equal :needs_attention, c.current_state + assert c.needs_attention? + end + + def test_states_were_set + [:needs_attention, :read, :closed, :awaiting_response, :junk].each do |s| + assert Conversation.states.include?(s) + end + end + + def test_event_methods_created + c = Conversation.create + %w(new_message! view! reply! close! junk! unjunk!).each do |event| + assert c.respond_to?(event) + end + end + + def test_query_methods_created + c = Conversation.create + %w(needs_attention? read? closed? awaiting_response? junk?).each do |event| + assert c.respond_to?(event) + end + end + + def test_transition_table + tt = Conversation.transition_table + + assert tt[:new_message].include?(SupportingClasses::StateTransition.new(:read, :needs_attention)) + assert tt[:new_message].include?(SupportingClasses::StateTransition.new(:closed, :needs_attention)) + assert tt[:new_message].include?(SupportingClasses::StateTransition.new(:awaiting_response, :needs_attention)) + end + + def test_next_state_for_event + c = Conversation.create + assert_equal :read, c.next_state_for_event(:view) + end + + def test_change_state + c = Conversation.create + c.view! + assert c.read? + end + + def test_can_go_from_read_to_closed_because_guard_passes + c = Conversation.create + c.can_close = true + c.view! + c.reply! + c.close! + assert_equal :closed, c.current_state + end + + def test_cannot_go_from_read_to_closed_because_of_guard + c = Conversation.create + c.can_close = false + c.view! + c.reply! + c.close! + assert_equal :read, c.current_state + end + + def test_ignore_invalid_events + c = Conversation.create + c.view! + c.junk! + + # This is the invalid event + c.new_message! + assert_equal :junk, c.current_state + end + + def test_entry_action_executed + c = Conversation.create + c.read_enter = false + c.view! + assert c.read_enter + end + + def test_exit_action_executed + c = Conversation.create + c.read_exit = false + c.view! + c.junk! + assert c.read_exit + end + + def test_entry_and_exit_not_run_on_loopback_transition + c = Conversation.create + c.view! + c.read_enter = false + c.read_exit = false + c.view! + assert !c.read_enter + assert !c.read_exit + end + + def test_run_transition_action_is_private + c = Conversation.create + assert_raise(NoMethodError) { c.run_transition_action :foo } + end + + def test_find_all_in_state + cs = Conversation.find_in_state(:all, :read) + + assert_equal 2, cs.size + end + + def test_find_first_in_state + c = Conversation.find_in_state(:first, :read) + + assert_equal conversations(:first).id, c.id + end + + def test_find_all_in_state_with_conditions + cs = Conversation.find_in_state(:all, :read, :conditions => ['subject = ?', conversations(:second).subject]) + + assert_equal 1, cs.size + assert_equal conversations(:second).id, cs.first.id + end + + def test_find_first_in_state_with_conditions + c = Conversation.find_in_state(:first, :read, :conditions => ['subject = ?', conversations(:second).subject]) + assert_equal conversations(:second).id, c.id + end + + def test_count_in_state + cnt0 = Conversation.count(['state_machine = ?', 'read']) + cnt = Conversation.count_in_state(:read) + + assert_equal cnt0, cnt + end + + def test_count_in_state_with_conditions + cnt0 = Conversation.count(['state_machine = ? AND subject = ?', 'read', 'Foo']) + cnt = Conversation.count_in_state(:read, ['subject = ?', 'Foo']) + + assert_equal cnt0, cnt + end + + def test_find_in_invalid_state_raises_exception + assert_raise(InvalidState) { + Conversation.find_in_state(:all, :dead) + } + end + + def test_count_in_invalid_state_raises_exception + assert_raise(InvalidState) { + Conversation.count_in_state(:dead) + } + end +end diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/database.yml b/tracks/vendor/plugins/acts_as_state_machine/test/database.yml new file mode 100644 index 00000000..f012c25c --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/database.yml @@ -0,0 +1,18 @@ +sqlite: + :adapter: sqlite + :dbfile: state_machine.sqlite.db +sqlite3: + :adapter: sqlite3 + :dbfile: state_machine.sqlite3.db +postgresql: + :adapter: postgresql + :username: postgres + :password: postgres + :database: state_machine_test + :min_messages: ERROR +mysql: + :adapter: mysql + :host: localhost + :username: rails + :password: + :database: state_machine_test \ No newline at end of file diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversation.rb b/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversation.rb new file mode 100644 index 00000000..e18bad63 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversation.rb @@ -0,0 +1,50 @@ +class Conversation < ActiveRecord::Base + attr_writer :can_close + attr_accessor :read_enter, :read_exit + + acts_as_state_machine :initial => :needs_attention, :column => 'state_machine' + + state :needs_attention + state :read, :enter => :read_enter_action, + :exit => Proc.new { |o| o.read_exit = true } + state :closed + state :awaiting_response + state :junk + + event :new_message do + transitions :to => :needs_attention, :from => [:read, :closed, :awaiting_response] + end + + event :view do + transitions :to => :read, :from => [:needs_attention, :read] + end + + event :reply do + transitions :to => :awaiting_response, :from => [:read, :closed] + end + + event :close do + transitions :to => :closed, :from => [:read, :awaiting_response], :guard => Proc.new {|o| o.can_close?} + transitions :to => :read, :from => [:read, :awaiting_response], :guard => :always_true + end + + event :junk do + transitions :to => :junk, :from => [:read, :closed, :awaiting_response] + end + + event :unjunk do + transitions :to => :closed, :from => :junk + end + + def can_close? + @can_close + end + + def read_enter_action + self.read_enter = true + end + + def always_true + true + end +end diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversations.yml b/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversations.yml new file mode 100644 index 00000000..572e0ee1 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversations.yml @@ -0,0 +1,11 @@ +first: + id: 1 + state_machine: read + subject: This is a test + closed: false + +second: + id: 2 + state_machine: read + subject: Foo + closed: false diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/person.rb b/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/person.rb new file mode 100644 index 00000000..e64a8262 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/fixtures/person.rb @@ -0,0 +1,2 @@ +class Person < ActiveRecord::Base +end \ No newline at end of file diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/schema.rb b/tracks/vendor/plugins/acts_as_state_machine/test/schema.rb new file mode 100644 index 00000000..d79ee4a7 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema.define(:version => 1) do + create_table :conversations do |t| + t.column :state_machine, :string + t.column :subject, :string + t.column :closed, :boolean + end + + create_table :people do |t| + t.column :name, :string + end +end diff --git a/tracks/vendor/plugins/acts_as_state_machine/test/test_helper.rb b/tracks/vendor/plugins/acts_as_state_machine/test/test_helper.rb new file mode 100644 index 00000000..95331d03 --- /dev/null +++ b/tracks/vendor/plugins/acts_as_state_machine/test/test_helper.rb @@ -0,0 +1,38 @@ +$:.unshift(File.dirname(__FILE__) + '/../lib') +RAILS_ROOT = File.dirname(__FILE__) + +require 'rubygems' +require 'test/unit' +require 'active_record' +require 'active_record/fixtures' +require 'active_support/binding_of_caller' +require 'active_support/breakpoint' +require "#{File.dirname(__FILE__)}/../init" + + +config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml')) +ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log") +ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite']) + +load(File.dirname(__FILE__) + "/schema.rb") if File.exist?(File.dirname(__FILE__) + "/schema.rb") + +Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/" +$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path) + +class Test::Unit::TestCase #:nodoc: + def create_fixtures(*table_names) + if block_given? + Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield } + else + Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) + end + end + + # Turn off transactional fixtures if you're working with MyISAM tables in MySQL + self.use_transactional_fixtures = true + + # Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david) + self.use_instantiated_fixtures = false + + # Add more helper methods to be used by all tests here... +end \ No newline at end of file