A code autograder for student homework submissions
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

233 lines
5.5 KiB

  1. module KGrader
  2. class Submission
  3. attr_reader :course, :semester, :assignment, :student
  4. MAX_COLS = 79
  5. def initialize(filesystem, course, semester, assignment, student)
  6. @fs = filesystem
  7. @course = course
  8. @semester = semester
  9. @assignment = assignment
  10. @student = student
  11. @root = @fs.submission @course.name, @semester, @assignment.name, student
  12. @status = nil
  13. end
  14. def status
  15. @status ||= @fs.load(statusfile).to_sym
  16. end
  17. def status=(new_status)
  18. File.write statusfile, new_status
  19. @status = new_status
  20. end
  21. def exists?
  22. File.exists? statusfile
  23. end
  24. def create
  25. FileUtils.mkdir_p @root
  26. self.status = :init
  27. nil
  28. end
  29. def fetch(due)
  30. if status == :init
  31. @course.backend.clone repo, @semester, @assignment.id, @student
  32. rewind due
  33. self.status = :ungraded
  34. else
  35. oldrev = revision if status == :graded
  36. self.status = :fetching
  37. @course.backend.update repo
  38. newrev = rewind due
  39. self.status = newrev == oldrev ? :graded : :ungraded
  40. end
  41. nil
  42. end
  43. def grade
  44. grade_prep
  45. stage
  46. build
  47. test
  48. save
  49. grade_post
  50. @summary
  51. end
  52. def commit
  53. if status == :graded && File.exists?(pendingfile)
  54. message = @assignment.commit_message @student
  55. FileUtils.cp gradefile, File.join(repo, @assignment.report)
  56. @course.backend.commit repo, message, @assignment.report
  57. FileUtils.rm pendingfile
  58. end
  59. nil
  60. end
  61. private
  62. def repo
  63. File.join @root, 'repo'
  64. end
  65. def statusfile
  66. File.join @root, 'status.txt'
  67. end
  68. def gradefile
  69. File.join @root, 'grade.txt'
  70. end
  71. def pendingfile
  72. File.join @root, 'pending'
  73. end
  74. def buildlog
  75. File.join @root, 'build.log'
  76. end
  77. def testlog
  78. File.join @root, 'test.log'
  79. end
  80. def revision
  81. @course.backend.revision repo
  82. end
  83. def rewind(date)
  84. log = @course.backend.log repo
  85. target = log.find { |commit| commit[:date] <= date }
  86. if target.nil?
  87. raise SubmissionError, "no commits before due date: #{student}"
  88. end
  89. rev = target[:rev]
  90. @course.backend.update repo, rev
  91. rev
  92. end
  93. def grade_prep
  94. @failure = false
  95. @comments = []
  96. @summary = nil
  97. @tests = @assignment.tests.clone.each { |test| test[:score] = 0 }
  98. self.status = :ungraded
  99. FileUtils.rm_f [buildlog, testlog]
  100. @fs.jail.reset
  101. @fs.jail.init
  102. end
  103. def stage
  104. @assignment.manifest[:provided].each do |entry|
  105. @fs.jail.stage entry[:path], entry[:name]
  106. end
  107. @assignment.manifest[:graded].each do |entry|
  108. @fs.jail.stage File.join(repo, entry[:name]), entry[:name]
  109. end
  110. end
  111. def build
  112. @assignment.build_steps.each do |command|
  113. return build_failure unless @fs.jail.exec command, buildlog
  114. end
  115. end
  116. def test
  117. return if @failure
  118. @tests.each do |test|
  119. test[:score] = @fs.jail.run_test test[:script], testlog do |comment|
  120. @comments.push "#{test[:name]}: #{comment}"
  121. end
  122. end
  123. end
  124. def save
  125. File.write gradefile, generate_report
  126. FileUtils.touch pendingfile
  127. end
  128. def grade_post
  129. self.status = :graded
  130. @fs.jail.reset
  131. @summary = generate_summary unless @summary
  132. end
  133. def build_failure
  134. @failure = true
  135. @comments.push "failed to compile"
  136. @summary = "#{format_points 0, max_score}: failed to compile"
  137. end
  138. def generate_report
  139. header = "#{assignment.title} Grade Report for #{student}"
  140. header = header.center(MAX_COLS).rstrip
  141. hr1 = '-' * MAX_COLS
  142. hr2 = '=' * MAX_COLS
  143. metadata = [
  144. "commit revision: #{revision}",
  145. "commit date: #{format_time @course.backend.commit_date(repo)}",
  146. "grade date: #{format_time Time.now}"
  147. ]
  148. version = KGrader.version
  149. metadata.push "grader version: #{version}" if version
  150. metadata = metadata.join("\n")
  151. tests = "tests:\n" + @tests.map do |test|
  152. score = format_points(test[:score], test[:max], max_score)
  153. justify_both " - #{test[:name]}", score
  154. end.join("\n")
  155. total = justify_both "total:", format_points(score, max_score)
  156. all_comments = (@comments + @assignment.extra_comments)
  157. if all_comments
  158. comments = "comments:\n" + all_comments.map do |cmt|
  159. " - #{cmt}\n"
  160. end.join
  161. else
  162. comments = ""
  163. end
  164. [header, hr2, metadata, hr1, tests, hr1, total, hr1, comments].join "\n"
  165. end
  166. def generate_summary
  167. tests = @tests.map do |test|
  168. "#{test[:score].to_s.rjust get_span(test[:max])}/#{test[:max]}"
  169. end.join ', '
  170. "#{format_points score, max_score}: #{tests}"
  171. end
  172. def score
  173. @tests.reduce(0) { |sum, t| sum + t[:score] }
  174. end
  175. def max_score
  176. @tests.reduce(0) { |sum, t| sum + t[:max] }
  177. end
  178. def format_points(score, max, span_max = nil)
  179. percent = (score.to_f * 100 / max).round.to_s.rjust 3
  180. span = get_span(span_max || max)
  181. "#{percent}% (#{score.to_s.rjust span}/#{max.to_s.rjust span})"
  182. end
  183. def justify_both left, right
  184. "#{left}#{right.rjust MAX_COLS - left.length}"
  185. end
  186. def get_span(max)
  187. (Math.log10(max) + 1).to_i
  188. end
  189. def format_time(time)
  190. time.localtime.strftime "%H:%M, %b %d, %Y %Z"
  191. end
  192. end
  193. end