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.

232 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. end
  60. private
  61. def repo
  62. File.join @root, 'repo'
  63. end
  64. def statusfile
  65. File.join @root, 'status.txt'
  66. end
  67. def gradefile
  68. File.join @root, 'grade.txt'
  69. end
  70. def pendingfile
  71. File.join @root, 'pending'
  72. end
  73. def buildlog
  74. File.join @root, 'build.log'
  75. end
  76. def testlog
  77. File.join @root, 'test.log'
  78. end
  79. def revision
  80. @course.backend.revision repo
  81. end
  82. def rewind(date)
  83. log = @course.backend.log repo
  84. target = log.find { |commit| commit[:date] <= date }
  85. if target.nil?
  86. raise SubmissionError, "no commits before due date: #{student}"
  87. end
  88. rev = target[:rev]
  89. @course.backend.update repo, rev
  90. rev
  91. end
  92. def grade_prep
  93. @failure = false
  94. @comments = []
  95. @summary = nil
  96. @tests = @assignment.tests.clone.each { |test| test[:score] = 0 }
  97. self.status = :ungraded
  98. FileUtils.rm_f [buildlog, testlog]
  99. @fs.jail.reset
  100. @fs.jail.init
  101. end
  102. def stage
  103. @assignment.manifest[:provided].each do |entry|
  104. @fs.jail.stage entry[:path], entry[:name]
  105. end
  106. @assignment.manifest[:graded].each do |entry|
  107. @fs.jail.stage File.join(repo, entry[:name]), entry[:name]
  108. end
  109. end
  110. def build
  111. @assignment.build_steps.each do |command|
  112. return build_failure unless @fs.jail.exec command, buildlog
  113. end
  114. end
  115. def test
  116. return if @failure
  117. @tests.each do |test|
  118. test[:score] = @fs.jail.run_test test[:script], testlog do |comment|
  119. @comments.push "#{test[:name]}: #{comment}"
  120. end
  121. end
  122. end
  123. def save
  124. File.write gradefile, generate_report
  125. FileUtils.touch pendingfile
  126. end
  127. def grade_post
  128. # self.status = :graded # TODO: uncomment
  129. # @fs.jail.reset # TODO: uncomment
  130. @summary = generate_summary unless @summary
  131. end
  132. def build_failure
  133. @failure = true
  134. @comments.push "failed to compile"
  135. @summary = "#{format_points 0, max_score}: failed to compile"
  136. end
  137. def generate_report
  138. header = "#{assignment.title} Grade Report for #{student}"
  139. header = header.center(MAX_COLS).rstrip
  140. hr1 = '-' * MAX_COLS
  141. hr2 = '=' * MAX_COLS
  142. metadata = [
  143. "commit revision: #{revision}",
  144. "commit date: #{format_time @course.backend.commit_date(repo)}",
  145. "grade date: #{format_time Time.now}"
  146. ]
  147. version = KGrader.version
  148. metadata.push "grader version: #{version}" if version
  149. metadata = metadata.join("\n")
  150. tests = "tests:\n" + @tests.map do |test|
  151. score = format_points(test[:score], test[:max], max_score)
  152. justify_both " - #{test[:name]}", score
  153. end.join("\n")
  154. total = justify_both "total:", format_points(score, max_score)
  155. all_comments = (@comments + @assignment.extra_comments)
  156. if all_comments
  157. comments = "comments:\n" + all_comments.map do |cmt|
  158. " - #{cmt}\n"
  159. end.join
  160. else
  161. comments = ""
  162. end
  163. [header, hr2, metadata, hr1, tests, hr1, total, hr1, comments].join "\n"
  164. end
  165. def generate_summary
  166. tests = @tests.map do |test|
  167. "#{test[:score].to_s.rjust get_span(test[:max])}/#{test[:max]}"
  168. end.join ', '
  169. "#{format_points score, max_score}: #{tests}"
  170. end
  171. def score
  172. @tests.reduce(0) { |sum, t| sum + t[:score] }
  173. end
  174. def max_score
  175. @tests.reduce(0) { |sum, t| sum + t[:max] }
  176. end
  177. def format_points(score, max, span_max = nil)
  178. percent = (score.to_f * 100 / max).round.to_s.rjust 3
  179. span = get_span(span_max || max)
  180. "#{percent}% (#{score.to_s.rjust span}/#{max.to_s.rjust span})"
  181. end
  182. def justify_both left, right
  183. "#{left}#{right.rjust MAX_COLS - left.length}"
  184. end
  185. def get_span(max)
  186. (Math.log10(max) + 1).to_i
  187. end
  188. def format_time(time)
  189. time.localtime.strftime "%H:%M, %b %d, %Y %Z"
  190. end
  191. end
  192. end