Skip to content
Browse files

Implemented report notes

* SR::Report::Notes class
* Report is subclass of Command
* Command gets 'emit' method for writing to output stream
* test/report_notes.rb
* Name and Student get proper hash and eql? implementation
* Added notes to test/db/notes.yaml for more detailed testing
  • Loading branch information...
1 parent ecf2b1f commit ccdba93b70add5027c2ec2174051f336195e2d6c @gsinclair committed Jan 29, 2012
View
228 doc/devel-log/2012-01-29.markdown
@@ -0,0 +1,228 @@
+# Report on notes
+
+The last thing I did was implement the "note" command.
+
+ sr note 9 EmmaD "Solved challenging problem"
+
+That required a lot of work: SchoolClass, Database. I'm not sure what to bite
+off next, but think I'll try a report on notes.
+
+ sr report notes 11 # whole class
+ sr report notes 11 JBla # just Jessica Blake
+ sr report notes 11 rp1 # reporting period 1
+
+That last one isn't going to get done now, and probably not even soon. But the
+other two are achievable. I'm going to keep the output basic at this stage. In
+designing the Report::Notes class, it's important to keep testability in mind,
+so I'm not entirely dependent on the command-line to test it.
+
+Example output:
+
+ $ sr report notes 11
+
+ Abigail Blake
+ 2 Feb Incomplete homework
+ 9 Mar Good assignment submission
+ 11 Mar Argumentative
+
+ Jenny Garvin
+ 1 Feb Uncooperative in completing work
+ 21 Feb Well behaved
+
+ ...
+
+ $ sr report notes 11 JG
+
+ Jenny Garvin
+ 1 Feb Uncooperative in completing work
+ 21 Feb Well behaved
+
+So I need my first Report class, SR::Report::Notes. At the moment, the command
+looks like this:
+
+ class SR::Command::ReportCmd < SR::Command
+ def run(args)
+ puts "Command: report"
+ puts "Arguments: #{args.inspect}"
+ end
+ end
+
+To run the report command, we need to work out what kind of report it is. The
+first argument ("notes") determines that, so we will create the appropriate
+class based on that argument, just like App#run does it.
+
+I've put some skeleton in place. Notice the 'out' parameter, meaning you can
+have a report generated into a StringIO for testing. This code is _very_
+similar to the Command class.
+
+ # lib/school_record/command.rb
+
+ class SR::Command::ReportCmd < SR::Command
+ REPORTS = {
+ notes: SR::Report::Notes,
+ lessons: SR::Report::Lessons,
+ day: SR::Report::Day,
+ week: SR::Report::Week,
+ homework: SR::Report::Homework,
+ }
+
+ def initialize(db, out=nil)
+ @out = out || STDOUT
+ end
+
+ def run(args)
+ report_type = args.shift
+ if report_type.nil?
+ help
+ else
+ class_for_report(report_type).new(@out).run(args)
+ end
+ end
+
+ private
+ def class_for_report(report_type)
+ report_type = report_type.to_sym
+ if REPORTS.key?(report_type)
+ REPORTS[report_type]
+ else
+ sr_err :invalid_report_type, report_type
+ end
+ end
+ end
+
+ # lib/school_record/report.rb
+
+ module SchoolRecord
+ # SchoolRecord::Report is a namespace to hold reports of various types. E.g.
+ # SR::Report::Homework generates a homework report for a given class or
+ # student.
+ class Report
+ def initialize(db, out=nil)
+ @db = db
+ @out = out || STDOUT
+ end
+ # Emits a line of report.
+ # emit "foo"
+ # emit "foo", :rb # red, bold
+ def emit(str, col_format_code=nil)
+ if col_format_code
+ str = Col(str).fmt(col_format_code)
+ end
+ @out.puts str
+ end
+ # The various reports are defined below.
+ end
+ end
+
+ # Generates a report on the notes recorded for a certain student, or for all
+ # students in a class.
+ class SR::Report::Notes < SR::Report
+ def run(args)
+ emit "Hi", :yb
+ end
+ end
+
+When I run `run report notes`, it emits "Hi" in yellow bold, exactly as written.
+Now to implement SR::Report::Notes#run properly.
+
+What does a notes report do?
+
+* Checks that the arguments are correct. Must be something like ['9'] or
+ ['11', 'JaneD'].
+* Asks the database for all notes for a given class, or all notes for the given
+ student (the Database class handles both cases).
+ * If it's for the whole class, we would group it into students. (?)
+* Emits a series of lines to the output to communicate the results.
+
+Pretty simple, really.
+
+ # Generates a report on the notes recorded for a certain student, or for all
+ # students in a class.
+ class SR::Report::Notes < SR::Report
+ def run(args)
+ required_arguments args, 1..2
+ class_label, name_fragment = check_arguments(args)
+ if name_fragment
+ student = @db.resolve_student!(class_label, name_fragment)
+ notes = @db.notes(class_label, name_fragment)
+ if notes.empty?
+ emit "No notes for #{student}"
+ else
+ emit_student_notes(student, notes)
+ end
+ else
+ @db.notes(class_label).group_by { |n| n.student }.each do |student, notes|
+ emit
+ emit_student_notes(student, notes)
+ end
+ end
+ end
+
+ def usage_text
+ text = %{
+ = The 'notes' report requires one or two arguments. E.g.
+ = school_record report notes 11
+ = school_record report notes 11 JCon
+ }.margin
+ end
+
+ private
+ # Return class label and optional name fragment. Error if args doesn't match
+ # this requirement. The number of arguments is checked separately.
+ def check_arguments(args)
+ class_label, name_fragment = args.shift(2)
+ unless @db.valid_class_label? class_label
+ sr_err :invalid_class_label, class_label
+ end
+ [class_label, name_fragment]
+ end
+
+ def emit_student_notes(student, notes)
+ emit student, :yb
+ notes.sort_by { |n| n.date }.each do |n|
+ emit " #{n.date} #{n.text}"
+ end
+ end
+ end # class SR::Report::Notes
+
+And it works:
+
+ $ run report notes 9
+
+ Mikaela Achie (9)
+ 2012-01-28 Equipment
+
+ Anna Kirkby (9)
+ 2012-01-28 Equipment
+
+
+ $ run report notes 9 AK
+ Anna Kirkby (9)
+ 2012-01-28 Equipment
+
+
+The date format is not nice, though. I want "28 Jan", not "2012-01-28". In
+future I will probably want "28 Jan" _and_ "2B-Wed", but that's in future...
+
+I introduced `SR::Util.day_month(date)` rather than fiddle around with date
+format strings everywhere.
+
+## Conclusion
+
+That reporting code was easy to write, and I ended up improving some other code
+as well:
+
+* Report is now a subclass of Command, so all reports get their database and
+ output stream initialization for free. Reports can use required\_arguments
+ and implement usage\_text to print a help message if the wrong number of
+ arguments are given.
+* The 'emit' method moved to Command, so all commands can take advantage of it.
+ The 'note' command now uses emit instead of puts.
+
+Just gotta write some tests and it's time to commit.
+
+The tests exposed an error: a report on a whole class was not sorting the
+students in alphabetical order.
+
+Another error exposed. Students were not being grouped in a class. Needed to
+implement eql? and hash in my value objects properly.
View
8 lib/school_record.rb
@@ -1,20 +1,16 @@
require 'debuglog'
require 'pry'
-class Object
- def in?(collection)
- collection.include? self
- end
-end
-
module SchoolRecord
# All contents defined in other files.
end
SR = SchoolRecord # Alias for convenience, used throughout the code.
+require 'school_record/util'
require 'school_record/err'
require 'school_record/app'
+require 'school_record/report'
require 'school_record/command'
require 'school_record/version'
require 'school_record/domain_objects'
View
20 lib/school_record/app.rb
@@ -3,7 +3,8 @@
module SchoolRecord
class App
- def initialize
+ def initialize(out=nil)
+ @out ||= STDOUT
end
COMMANDS = {
@@ -13,14 +14,6 @@ def initialize
config: SR::Command::ConfigCmd
}
- def class_for_command(command)
- if COMMANDS.key? command.to_sym
- COMMANDS[command.to_sym]
- else
- sr_err :invalid_command, command
- end
- end
-
def run(args)
puts "school-record version #{SchoolRecord::VERSION}"
command = args.shift
@@ -32,6 +25,15 @@ def run(args)
end
end
+ private
+ def class_for_command(command)
+ if COMMANDS.key? command.to_sym
+ COMMANDS[command.to_sym]
+ else
+ sr_err :invalid_command, command
+ end
+ end
+
def help
puts "Help message goes here..."
puts "Valid commands: #{COMMANDS.keys.join(', ')}"
View
72 lib/school_record/command.rb
@@ -6,31 +6,50 @@ module SchoolRecord
# namespace. The generic Command class takes care of storing the Database
# object that all of them will rely on.
class Command
- def initialize(db)
+ def initialize(db, out=nil)
@db = db
+ @out = out || STDOUT
end
def run(args)
- sr_int "Can't run generic Command object"
+ sr_int "Implement #run in subclass"
end
+ # required_arguments args, 3
+ # required_arguments args, 1..2
def required_arguments(args, n)
- unless args.size == n
+ n = (n..n) if Fixnum === n
+ unless n.include? args.size
STDERR.puts usage_text()
exit!
end
args
end
+ def usage_text
+ sr_int "Implement #usage_text in subclass"
+ end
+ # Emits a line of text to the output stream, optionally colouring it.
+ # emit "foo"
+ # emit "foo", :rb # red, bold
+ def emit(str="", col_format_code=nil)
+ str = str.to_s
+ if col_format_code
+ str = Col(str).fmt(col_format_code)
+ end
+ @out.puts str
+ end
end
end
+# --------------------------------------------------------------------------- #
+
class SR::Command::NoteCmd < SR::Command
def run(args)
class_label, name_fragment, text = required_arguments(args, 3)
student = @db.resolve_student!(class_label, name_fragment)
- puts "Saving note for student: #{student}"
+ emit "Saving note for student: #{student}"
note = SR::DO::Note.new(Date.today, student, text)
@db.save_note(note)
- puts "Contents of notes file:"
- puts @db.contents_of_notes_file.indent(4)
+ emit "Contents of notes file:"
+ emit @db.contents_of_notes_file.indent(4)
end
def usage_text
msg = %{
@@ -44,20 +63,51 @@ def usage_text
end
end
-class SR::Command::EditCmd < SR::Command
+# --------------------------------------------------------------------------- #
+
+require 'school_record/report'
+
+class SR::Command::ReportCmd < SR::Command
+
+ REPORTS = {
+ notes: SR::Report::Notes,
+# lessons: SR::Report::Lessons,
+# day: SR::Report::Day,
+# week: SR::Report::Week,
+# homework: SR::Report::Homework,
+ }
+
def run(args)
- puts "Command: edit"
- puts "Arguments: #{args.inspect}"
+ report_type = args.shift
+ if report_type.nil?
+ help
+ else
+ class_for_report(report_type).new(@db, @out).run(args)
+ end
+ end
+
+ private
+ def class_for_report(report_type)
+ report_type = report_type.to_sym
+ if REPORTS.key?(report_type)
+ REPORTS[report_type]
+ else
+ sr_err :invalid_report_type, report_type
+ end
end
end
-class SR::Command::ReportCmd < SR::Command
+# --------------------------------------------------------------------------- #
+
+class SR::Command::EditCmd < SR::Command
def run(args)
- puts "Command: report"
+ puts "Command: edit"
puts "Arguments: #{args.inspect}"
end
end
+# --------------------------------------------------------------------------- #
+
class SR::Command::ConfigCmd < SR::Command
def run(args)
puts "Command: config"
View
20 lib/school_record/domain_objects.rb
@@ -16,6 +16,16 @@ def fullname
@fullname ||= "#{@first} #{@last}"
end
def to_s() fullname end
+ def hash
+ [self.class, @first, @last].hash
+ end
+ def eql?(other)
+ self.equal?(other) ||
+ self.class == other.class &&
+ self.first == other.first &&
+ self.last == other.last
+ end
+ alias == eql?
end
# A Student has a name and a class label.
@@ -33,6 +43,16 @@ def fullname() @name.fullname end
def to_s
"#{first} #{last} (#{class_label})"
end
+ def hash
+ [self.class, @name, @class_label].hash
+ end
+ def eql?(other)
+ self.equal?(other) ||
+ self.class.equal?(other.class) &&
+ self.name == other.name &&
+ self.class_label == other.class_label
+ end
+ alias == eql?
end # class Student
# A Note has a date, a Student, and some text.
View
6 lib/school_record/err.rb
@@ -49,5 +49,11 @@ def invalid_name_fragment *args
msg = "Cannot resolve name fragment #{fragment}: the format is invalid"
raise SR::SRError, msg
end
+
+ def invalid_class_label *args
+ label = args.shift
+ msg = "Invalid class label: #{label}"
+ raise SR::SRError, msg
+ end
end
end
View
69 lib/school_record/report.rb
@@ -0,0 +1,69 @@
+
+module SchoolRecord
+ # SchoolRecord::Report is a namespace to hold reports of various types. E.g.
+ # SR::Report::Homework generates a homework report for a given class or
+ # student.
+ #
+ # A Report is a type of Command: you initialize it with a database and
+ # optional output stream, and then you run it.
+ class Report < SR::Command
+ # The various reports are defined below.
+ end
+end
+
+# --------------------------------------------------------------------------- #
+
+# Generates a report on the notes recorded for a certain student, or for all
+# students in a class.
+class SR::Report::Notes < SR::Report
+ def run(args)
+ required_arguments args, 1..2
+ class_label, name_fragment = check_arguments(args)
+ if name_fragment
+ student = @db.resolve_student!(class_label, name_fragment)
+ notes = @db.notes(class_label, name_fragment)
+ if notes.empty?
+ emit "No notes for #{student}"
+ else
+ emit_student_notes(student, notes)
+ end
+ else
+ notes_per_student = @db.notes(class_label).group_by { |note| note.student }
+ students = notes_per_student.keys.sort_by { |s| [s.name.last, s.name.first] }
+ students.each do |student|
+ notes = notes_per_student[student]
+ emit
+ emit_student_notes(student, notes)
+ end
+ end
+ end
+
+ def usage_text
+ text = %{
+ = The 'notes' report requires one or two arguments. E.g.
+ = school_record report notes 11
+ = school_record report notes 11 JCon
+ }.margin
+ end
+
+ private
+ # Return class label and optional name fragment. Error if args doesn't match
+ # this requirement. The number of arguments is checked separately.
+ def check_arguments(args)
+ class_label, name_fragment = args.shift(2)
+ unless @db.valid_class_label? class_label
+ sr_err :invalid_class_label, class_label
+ end
+ [class_label, name_fragment]
+ end
+
+ def emit_student_notes(student, notes)
+ emit student, :yb
+ trace "notes.map { |n| n.date.to_s }", binding if student.name.first == "Isabella"
+ notes.sort_by { |n| n.date }.each do |n|
+ emit " #{SR::Util.day_month(n.date)} #{n.text}"
+ end
+ end
+end # class SR::Report::Notes
+
+# --------------------------------------------------------------------------- #
View
16 lib/school_record/util.rb
@@ -0,0 +1,16 @@
+
+class Object
+ # Why oh why is this not in the language?
+ def in?(collection)
+ collection.include? self
+ end
+end
+
+module SchoolRecord
+ class Util
+ # Returs a string like "13 Jan" or " 2 Mar" (note blank padding).
+ def Util.day_month(date)
+ date.strftime("%e %b")
+ end
+ end
+end
View
14 test/database.rb
@@ -21,15 +21,17 @@
D "It can access the saved notes ('notes' method)" do
notes = @db.notes('9')
Ko notes, Array
- Eq notes.size, 1
+ Eq notes.size, 3
Eq notes.first.student.fullname, "Mikaela Achie"
Eq notes.first.text, "Missing equipment"
notes = @db.notes('11')
- Eq notes.size, 2
- Eq notes.first.student.fullname, "Isabella Henderson"
- Eq notes.first.text, "Assignment not submitted"
- Eq notes.last.student.fullname, "Anna Burke"
- Eq notes.last.text, "Good work on board"
+ Eq notes.size, 3
+ Eq notes[0].student.fullname, "Isabella Henderson"
+ Eq notes[0].text, "Assignment not submitted"
+ Eq notes[1].student.fullname, "Isabella Henderson"
+ Eq notes[1].text, "Assignment submitted late"
+ Eq notes[2].student.fullname, "Anna Burke"
+ Eq notes[2].text, "Good work on board"
notes = @db.notes('11', 'ABur')
Eq notes.size, 1
Eq notes.first.student.fullname, "Anna Burke"
View
1 test/db/class-lists.yaml
@@ -43,6 +43,7 @@ Year9:
- Kench, Charlotte
- Kerr, Ella
- Kirkby, Anna
+ - Kirkby, Angela
- Kostic, Rachel
- Lowe, Sarah
- Marandos, Zoe
View
24 test/db/notes.yaml
@@ -8,6 +8,22 @@
last: Achie
class_label: '9'
text: Missing equipment
+- !ruby/object:SchoolRecord::DomainObjects::Note
+ date: 2012-04-20
+ student: !ruby/object:SchoolRecord::DomainObjects::Student
+ name: !ruby/object:SchoolRecord::DomainObjects::Name
+ first: Anna
+ last: Kirkby
+ class_label: '9'
+ text: Talking too much
+- !ruby/object:SchoolRecord::DomainObjects::Note
+ date: 2012-05-13
+ student: !ruby/object:SchoolRecord::DomainObjects::Student
+ name: !ruby/object:SchoolRecord::DomainObjects::Name
+ first: Angela
+ last: Kirkby
+ class_label: '9'
+ text: Late to class
'11':
- !ruby/object:SchoolRecord::DomainObjects::Note
@@ -19,6 +35,14 @@
class_label: '11'
text: Assignment not submitted
- !ruby/object:SchoolRecord::DomainObjects::Note
+ date: 2012-02-03
+ student: !ruby/object:SchoolRecord::DomainObjects::Student
+ name: !ruby/object:SchoolRecord::DomainObjects::Name
+ first: Isabella
+ last: Henderson
+ class_label: '11'
+ text: Assignment submitted late
+- !ruby/object:SchoolRecord::DomainObjects::Note
date: 2012-01-28
student: !ruby/object:SchoolRecord::DomainObjects::Student
name: !ruby/object:SchoolRecord::DomainObjects::Name
View
57 test/report_notes.rb
@@ -0,0 +1,57 @@
+require 'stringio'
+
+D "Notes report" do
+ D.<< { @db = SR::Database.test }
+ D.< { @out = StringIO.new }
+
+ D "Report on a single student" do
+ SR::Report::Notes.new(@db, @out).run(['11', 'IHen'])
+ report = Col.uncolored(@out.string).lines.to_a
+ Mt report[0], /Isabella Henderson \(11\)/
+ Mt report[1], /28 Jan\s+Assignment not submitted/
+ Mt report[2], / 3 Feb\s+Assignment submitted late/
+ end
+
+ D "Report on a whole class (1)" do
+ SR::Report::Notes.new(@db, @out).run(['11'])
+ report = Col.uncolored(@out.string).lines.to_a
+ trace "report", binding
+ Mt report[0], /^$/
+ Mt report[1], /Anna Burke \(11\)/
+ Mt report[2], /28 Jan\s+Good work on board/
+ Mt report[3], /^$/
+ Mt report[4], /Isabella Henderson \(11\)/
+ Mt report[5], /28 Jan\s+Assignment not submitted/ # Note: date order
+ Mt report[6], / 3 Feb\s+Assignment submitted late/
+ end
+
+ D "Report on a whole class (2)" do
+ SR::Report::Notes.new(@db, @out).run(['9'])
+ report = Col.uncolored(@out.string).lines.to_a
+ Mt report[0], /^$/
+ Mt report[1], /Mikaela Achie \(9\)/
+ Mt report[2], /28 Jan\s+Missing equipment/
+ Mt report[3], /^$/
+ Mt report[4], /Angela Kirkby \(9\)/ # Note: Angela before Anna.
+ Mt report[5], /13 May\s+Late to class/
+ Mt report[6], /^$/
+ Mt report[7], /Anna Kirkby \(9\)/
+ Mt report[8], /20 Apr\s+Talking too much/
+ end
+
+ D "Error when student fragment is ambiguous" do
+ E(SR::SRError) { SR::Report::Notes.new(@db, @out).run(['9', 'AnKir']) }
+ Mt Whitestone.exception.message, /Multiple students match/
+ end
+
+ D "Error when class label is invalid" do
+ E(SR::SRError) { SR::Report::Notes.new(@db, @out).run(['x', 'JSmith']) }
+ Mt Whitestone.exception.message, /Invalid class label: x/
+ end
+
+ D "Message when there are no notes for given student" do
+ SR::Report::Notes.new(@db, @out).run(['11', 'JBla'])
+ report = Col.uncolored(@out.string).lines.to_a
+ Mt report[0], /No notes for Jessica Blake \(11\)/
+ end
+end

0 comments on commit ccdba93

Please sign in to comment.
Something went wrong with that request. Please try again.