A code autograder for student homework submissions
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

233 lignes
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