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
This commit is contained in:
lukemelia 2006-10-10 07:17:54 +00:00
parent 8b65f1e8ee
commit 57005432e2
28 changed files with 765 additions and 47 deletions

View file

@ -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

View file

@ -11,8 +11,10 @@
<div class="project_description"><%= sanitize(@project.description) %></div>
<% end -%>
<% if @project.done? -%>
<% if @project.completed? -%>
<p class="project_completed">Project has been marked as completed</p>
<% elsif @project.completed? -%>
<p class="project_completed">Project has been marked as hidden</p>
<% end -%>
<div class="items toggle_target">
<div id="p<%= project.id %>empty-nd" style="display:<%= @not_done.empty? ? 'block' : 'none'%>;">

View file

@ -3,14 +3,18 @@
%>
<tr>
<td width="150"><label for="project_name">Name:</label></td>
<td width="300"><%= text_field 'project', 'name', :class => 'project-name' %></td>
<td width="300"><%= text_field :project, 'name', :class => 'project-name' %></td>
</tr>
<tr>
<td width="150"><label for="project_description">Description (optional):</label></td>
<td width="300"><%= text_area 'project', 'description', "cols" => 30, "rows" => 4, :class => 'project-description' %></td>
<td width="300"><%= text_area :project, 'description', "cols" => 30, "rows" => 4, :class => 'project-description' %></td>
</tr>
<tr>
<td width="150"><label for="project_done">Project done?</label></td>
<td width="300"><%= check_box 'project', 'done', :class => 'project-done' %></td>
<td width="150"><label for="project_done">Project status:</label></td>
<td width="300">
<% ['active', 'hidden', 'completed'].each do | state | %>
<%= radio_button(:project, 'state', state, {:class => 'project-done'}) %> <%= state.titlecase %>
<% end %>
</td>
</tr>
<% @project = nil %>

View file

@ -9,8 +9,10 @@
<%= link_to( sanitize("#{project.name}"), :action => "show", :name => urlize(project.name) ) %><%= " (" + count_undone_todos(project,"actions") + ")" %>
</div>
<div class="buttons">
<% if project.done? -%>
<% if project.completed? -%>
<span class="grey">COMPLETED</span>
<% elsif project.hidden? -%>
<span class="grey">HIDDEN</span>
<% else -%>
<span class="grey">ACTIVE</span>
<% end -%>

View file

@ -13,36 +13,42 @@
<div class="container">
<div id="notes">
<h2>Notes</h2>
<div id="empty-n" style="display:<%= @notes.empty? ? 'block' : 'none'%>;">
<%= render :partial => "shared/empty",
<div class="add_note_link"><%= link_to_function( "Add a note", "Element.toggle('new-note'); Form.focusFirstElement('form-new-note');") %></div>
<h2>Notes</h2>
<div id="empty-n" style="display:<%= @notes.empty? ? 'block' : 'none'%>;">
<%= render :partial => "shared/empty",
:locals => { :message => "Currently there are no notes attached to this project"} %>
</div>
<%= render :partial => "note/notes_summary", :collection => @notes %>
</div>
<%= render :partial => "note/notes_summary", :collection => @notes %>
</div>
</div>
<div>
<% if @project.done? -%>
<%= button_to "Mark project as uncompleted", {:action => "toggle_project_done", :id => @project.id} %><br />
<% else -%>
<%= button_to "Mark project as completed", {:action => "toggle_project_done", :id => @project.id} %><br />
<% end -%>
<div id="new-note" style="display:none;">
<%= form_remote_tag :url => { :controller => "note", :action => "add" },
:update => "notes",
:position => "bottom",
:complete => "new Effect.Highlight('notes');$('empty-n').hide();",
:html => {:id=>'form-new-note', :class => 'inline-form'} %>
<%= hidden_field( "new_note", "project_id", "value" => "#{@project.id}" ) %>
<%= text_area( "new_note", "body", "cols" => 50, "rows" => 3, "tabindex" => 1 ) %>
<br /><br />
<input type="submit" value="Add note" tabindex="2" />
<%= end_form_tag %>
</div>
<%= link_to_function( "Add a note", "Element.toggle('new-note'); Form.focusFirstElement('form-new-note');") %>
<div id="new-note" style="display:none;">
<%= form_remote_tag :url => { :controller => "note", :action => "add" },
:update => "notes",
:position => "bottom",
:complete => "new Effect.Highlight('notes');$('empty-n').hide();",
:html => {:id=>'form-new-note', :class => 'inline-form'} %>
<%= hidden_field( "new_note", "project_id", "value" => "#{@project.id}" ) %>
<%= text_area( "new_note", "body", "cols" => 50, "rows" => 3, "tabindex" => 1 ) %>
<br /><br />
<input type="submit" value="Add note" tabindex="2" />
<%= end_form_tag %>
<div class="container">
<div id="status">
<h2>Status</h2>
<div>
<% ['active', 'hidden', 'completed'].each do | state | %>
<% js = "new Ajax.Request('#{ url_for :controller => 'project', :action => 'update', :id => @project.id, 'project[state]' => state }', {asynchronous:true, evalScripts:true});" %>
<%= radio_button(:project, 'state', state, {:class => 'project-done', :onclick => js}) %> <%= state.titlecase %>
<% end %>
</div>
</div>
</div>
</div>
</div><!-- [end:display_box] -->
<div id="input_box">

View file

@ -52,7 +52,7 @@
<br />
<label for="todo_project_id">Project</label><br />
<%= 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}) %>
<br />

View file

@ -46,7 +46,7 @@
</tr>
<tr>
<td><label for="todo_project_id">Project</label></td>
<td><%= select( "todo", "project_id", @projects.reject{|x| x.done? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %></td>
<td><%= select( "todo", "project_id", @projects.select{|x| x.active? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %></td>
</tr>
<tr>
<td><label for="todo_due">Due</label></td>

View file

@ -1,6 +1,6 @@
<h3>Active Projects:</h3>
<ul>
<% for project in @projects.reject{|p| p.done? } -%>
<% for project in @projects.select{|p| p.active? } -%>
<li id="sidebar-project-<%= project.id %>" class="sidebar-project"><%= link_to( sanitize(project.name), { :controller => "project", :action => "show",
:name => urlize(project.name) } ) + " (" + count_undone_todos(project,"actions") + ")" %></li>
<% end -%>
@ -9,7 +9,7 @@
<% if @user.preference.show_completed_projects_in_sidebar %>
<h3>Completed Projects:</h3>
<ul>
<% for project in @projects.reject{|p| !p.done? } -%>
<% for project in @projects.select{|p| p.completed? } -%>
<li id="sidebar-project-<%= project.id %>" class="sidebar-project"><%= link_to( sanitize(project.name), { :controller => "project", :action => "show",
:name => urlize(project.name) } ) + " (" + count_undone_todos(project,"actions") + ")" %></li>
<% end -%>

View file

@ -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

View file

@ -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|

View file

@ -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;}

View file

@ -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

View file

@ -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<project>\n <name>#{@@project_name}</name>\n <done type=\"integer\">0</done>\n <id type=\"integer\">[0-9]+</id>\n <description></description>\n <position type=\"integer\">1</position>\n</project>\n$|
assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n<project>\n <name>#{@@project_name}</name>\n <id type=\"integer\">[0-9]+</id>\n <description></description>\n <position type=\"integer\">1</position>\n <state>active</state>\n</project>$|
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"

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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!

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
require 'acts_as_state_machine'
ActiveRecord::Base.class_eval do
include RailsStudio::Acts::StateMachine
end

View file

@ -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 <tt>:to</tt> is the state to transition
# to and <tt>:from</tt> 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: <tt>order.close_order!</tt>.
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
class Person < ActiveRecord::Base
end

View file

@ -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

View file

@ -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