remarks (empty) → 0.1.0
raw patch · 12 files changed
+770/−0 lines, 12 filesdep +GenericPrettydep +basedep +directorysetup-changed
Dependencies added: GenericPretty, base, directory, filepath, remarks, tasty, tasty-golden, tasty-hunit
Files
- LICENSE +30/−0
- README.md +194/−0
- Setup.hs +2/−0
- app/Main.hs +130/−0
- remarks.cabal +54/−0
- src/Ast.hs +39/−0
- src/Parser.hs +11/−0
- src/Parser/Impl.hs +127/−0
- src/Validator.hs +41/−0
- test/Parser/BlackBoxTests.hs +69/−0
- test/Tests.hs +10/−0
- test/ValidatorTests.hs +63/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Oleks (c) 2017++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Oleks name here nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,194 @@+# `remarks` — A DSL for marking student work++[](LICENSE)+[](https://travis-ci.org/oleks/remarks)+[](https://ci.appveyor.com/project/oleks/remarks)++When judging student performance, it is useful to have both small, composable,+quantitative judgements, and qualitative remarks. This makes both spreadsheets+and mere text-files ill-suited for marking student work. Although+[org-mode](http://orgmode.org/) can solve this problem to a great extent, it+becomes a heavy tool in the light of having to mark hundreds of students in a+distributed fashion. With org-mode, everything is in one file, while global,+intra-student statistics are not needed until all the students have been fully+marked.++## Design Goals++ 1. One human-readable/editable file per student.+ 2. Export options to spreadsheet-formats.+ 3. git-friendly file format.+ 4. Synchronization options with Dropbox and/or Google Drive.++Goal 4 is not necessarily related to `remarks`, but is related to marking+student work with external examiners, who are not always willing to use more+explicit version-control systems, such as git.++## Status++There is a [parser](src/Parser/Impl.hs) and baseline+[validator](src/Validator.hs). These can be invoked using `remarks parse` and+`remarks check`, respectively.++See [Issues](https://github.com/oleks/remarks/issues) for a roadmap. Feel free+to add or fix some.++## Syntax++A `.mrk` file is a list of judgements.++A judgement starts with a header mark (a sequence of `#`), a title (followed by+a `:`), given points (followed by `/`), and maximum points (followed by a line+break). The number of `#` determines the /depth/ of the header, and every file+/must/ start at depth 1, but may have multiple depth 1 judgements. Headings may+be arbitrarily nested, but must sum up correctly. For instance, here is a file+containing only quantitative remarks:++```+# Theory: 27/50+## Question 1: 10/10+## Question 2: 10/20+## Question 3: 7/20+# Practice: 35/50+## Task 1: 20/25+## Task 2: 15/25+```++The header of a judgement may be followed by qualitative remarks. Remarks begin+with an indent (two spaces), and a mood mark:++ * `*` for neutral/structural remarks;+ * `+` for positive remarks;+ * `-` for negative remarks;+ * `?` for impartial remarks.++Impartial remarks are good for judgements where the mood is left to be judged+by a higher authority. For instance, when a teaching assistant is uncertain,+and would like to let the teacher decide, or the solution cannot be used to+make a judgement about this point.++Structural remarks are good for listing things to look for. For instance, a+(template) judgement for an operating systems exercise may look something like+this:++```+## T1: 15/15++## Formal requirements: 5/5+ * Has code+ * Has XML in a reloadable/testable form+ * Has an explanation++### Quality assessment: 10/10+ * Understands semaphores+ * Understands deadlocks+ * Understands starvation+ * Avoids deadlocks+ * Avoids starvation+ * Avoids race conditions+ * Has a good degree of multiprogramming+ * Solves the problem+```++Once filled in by a teaching assistant, this may look something like this:++```+## T1: 7/15++### Formal requirements: 5/5+ * Has code+ + Yes.+ * Has XML in a reloadable/testable form+ + Yes.+ * Has an explanation+ + Yes.++### Quality assessment: 2/10+ * Understands semaphores+ - Seems to treat them as counters. Report doesn't talk about procuring or+ vacating at all.+ - Checks semaphore value directly.+ - Seems to have mixed up procure and vacate.+ * Understands deadlocks+ - If more than 5 cars arrive, then only 5 cars will be allowed to drive+ through, and everything will then deadlock.+ * Understands starvation+ ? Seems to, but this is hard to judge.+ * Avoids deadlocks+ ? See above.+ * Avoids starvation+ ? Can't judge this.+ * Avoids race conditions+ - Uses a busy loop in attempt to synchronize.+ - There is a race condition after the busy loop.+ * Has a good degree of multiprogramming+ + It's a ticket system, so it could be okay.+ * Solves the problem+ + In a complicated way, but yes.+```++## Files and Directories++The file-format is kept "git-friendly" by keeping it comprehensible in+plain-text, and allowing for independent marking by splitting the remarks for a+student into multiple files.++The simplest setup is to have one `.mrk` file per student (e.g.+[basic.mrk](samples/organization/basic.mrk)).++To support more exotic setups, `remarks` can also work with directories:++ * If supplied with a directory path, `remarks` looks for files ending in+ `.mrk` inside that directory, and comprehends the files, as above, in+ lexicographic filename order (e.g.,+ [directory-with-mrk-files](samples/organization/directory-with-mrk-files)).++ * If there exists a directory `<basename>` for any `<basename>.mrk`,+ `<basename>` is recursively searched for further `.mrk` files. Their+ contents is appended, in lexicographic filename order, to the last+ top-level judgement of `<basename>.mrk`+ [mixed-directory](samples/organization/mixed-directory)).++See the [organization samples](samples/organization) for some examples of how+judgements may be structured using files and directories.++```+├── basic.mrk+├── directory-with-mrk-files+│ ├── 01-theory.mrk+│ └── 02-practice.mrk+└── mixed-directory+ ├── 01-theory.mrk+ ├── 02-practice+ │ ├── Task1.mrk+ │ └── Task2.mrk+ └── 02-practice.mrk+```++`basic.mrk`, `directory-with-mrk-files`, and `mixed-directory` all parse to the+same judgements. In particular, the output from the following `remarks`+commands is identical:++```+$ cd samples/organization/+$ remarks parse basic.mrk+$ remarks parse directory-with-mrk-files+$ remarks parse mixed-directory+```++## Installation++[`remarks` is on Hackage](http://hackage.haskell.org/package/remarks), so you+can just use [Cabal](https://www.haskell.org/cabal/):++```+$ cabal install remarks+```++You can also clone this repository and use+[Stack](https://docs.haskellstack.org/en/stable/README/):++```+$ stack build+$ stack install+```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,130 @@+module Main where++import Ast+import Parser+import Validator++import Control.Monad ( void, filterM )+import Data.List ( sort, partition )+import System.Directory+ ( doesFileExist, doesDirectoryExist, listDirectory )+import System.FilePath+ ( (<.>), (</>), takeDirectory, takeExtension, dropExtension )+import System.Environment( getArgs )+import System.Exit ( exitWith, ExitCode ( ExitFailure ) )+import System.IO ( hPutStrLn, stderr )++import Text.PrettyPrint.GenericPretty++report :: String -> IO ()+report = hPutStrLn stderr++noCommand :: IO a+noCommand = do+ report "Tell me what to do!"+ exitWith (ExitFailure 1)++notAPath :: String -> IO a+notAPath s = do+ report $ s ++ " is not a filesystem path!"+ exitWith (ExitFailure 1)++invalidCommand :: String -> [String] -> IO a+invalidCommand c _ = do+ report $ c ++ " is not a valid command."+ exitWith (ExitFailure 1)++noMrkFile :: FilePath -> IO a+noMrkFile path = do+ report $ "Can't find a .mrk file\n" ++ path+ exitWith (ExitFailure 1)++noTopLevelJudgement :: FilePath -> IO a+noTopLevelJudgement path = do+ report $ path ++ " has no top-level judgement."+ report "I don't know how to handle this, sorry."+ exitWith (ExitFailure 1)++mustHaveJudgement :: FilePath -> IO a+mustHaveJudgement path = do+ report $ path ++ " must have at least one judgement."+ exitWith (ExitFailure 1)++parseError :: ParseError -> IO a+parseError e = do+ report $ show e+ exitWith (ExitFailure 1)++parseTopFile :: FilePath -> IO [Judgement]+parseTopFile path = do+ p <- parseFile path+ case p of+ Right js -> pure js+ Left e -> parseError e++parseFileInDir :: FilePath -> IO Judgement+parseFileInDir path = do+ js <- parsePath path+ case js of+ [j] -> pure j+ _ -> noTopLevelJudgement path++parseFileWithDir :: FilePath -> IO ([Judgement], Judgement)+parseFileWithDir path = do+ p <- parseFile path+ case p of+ Right js ->+ case length js of+ 0 -> mustHaveJudgement path+ n -> pure $ (take (n - 1) js, last js)+ Left e -> parseError e++isMrkPath :: FilePath -> Bool+isMrkPath = (== ".mrk") . takeExtension++parseDir :: FilePath -> IO [Judgement]+parseDir path = do+ paths <- listDirectory path+ let (mrkPaths, otherPaths) = partition isMrkPath paths+ let fullMrkPaths = map (path </>) (sort mrkPaths)+ mapM parseFileInDir fullMrkPaths++extPaths :: FilePath -> (FilePath, FilePath)+extPaths path =+ if isMrkPath path+ then (path, dropExtension path)+ else (path <.> "mrk", path)++parsePath :: FilePath -> IO [Judgement]+parsePath path = do+ let (pathWithExt, pathWithoutExt) = extPaths path+ hasFile <- doesFileExist pathWithExt+ hasDir <- doesDirectoryExist pathWithoutExt++ if not hasFile+ then+ if hasDir+ then parseDir path+ else noMrkFile pathWithExt+ else -- we have a .mrk file+ if not hasDir+ then parseTopFile pathWithExt+ else do -- we now also have directory+ (fjs, (Judgement (h, cs, js))) <- parseFileWithDir pathWithExt+ dirJs <- parseDir pathWithoutExt+ pure $ fjs ++ [Judgement (h, cs, js ++ dirJs)]++parsePaths :: [FilePath] -> IO [[Judgement]]+parsePaths = mapM parsePath++check :: [Judgement] -> IO ()+check js = putStrLn $ pretty $ map validate js++main :: IO ()+main = do+ args <- getArgs+ case args of+ [] -> noCommand+ ("parse" : paths) -> parsePaths paths >>= putStrLn . pretty+ ("check" : paths) -> parsePaths paths >>= mapM_ check+ (c:args) -> invalidCommand c args
+ remarks.cabal view
@@ -0,0 +1,54 @@+name: remarks+version: 0.1.0+synopsis: A DSL for marking student work+description: A DSL for marking student work; see README.md for further details.+homepage: https://github.com/oleks/remarks#readme+license: BSD3+license-file: LICENSE+author: Oleks+maintainer: oleks@oleks.info+copyright: 2017 Oleks+category: Web+build-type: Simple+extra-source-files: README.md+cabal-version: >=1.10++library+ hs-source-dirs: src+ exposed-modules: Ast+ , Parser+ , Parser.Impl+ , Validator+ build-depends: base >= 4.7 && < 5+ , GenericPretty >= 1.2.1+ default-language: Haskell2010++executable remarks+ hs-source-dirs: app+ main-is: Main.hs+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ build-depends: base+ , remarks+ , GenericPretty >= 1.2.1+ , directory >= 1.2.6.2+ , filepath >= 1.4.1.0+ default-language: Haskell2010++test-suite remarks-test+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Tests.hs+ other-modules: Parser.BlackBoxTests+ ValidatorTests+ build-depends: base+ , remarks+ , GenericPretty >= 1.2.1+ , tasty >= 0.11.0.4+ , tasty-hunit >= 0.9.2+ , tasty-golden >= 2.3.1.1+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ default-language: Haskell2010++source-repository head+ type: git+ location: https://github.com/oleks/remarks
+ src/Ast.hs view
@@ -0,0 +1,39 @@+{-# LANGUAGE DeriveGeneric #-}++module Ast where++import Text.PrettyPrint.GenericPretty++newtype Header+ = Header (String, Double, Double)+ deriving (Eq, Show, Generic)++instance Out Header++data Mood+ = Positive+ | Negative+ | Neutral+ | Impartial+ deriving (Eq, Show, Generic)++instance Out Mood++data CommentPart+ = CommentStr String+ | CommentCmt Comment+ deriving (Eq, Show, Generic)++instance Out CommentPart++newtype Comment+ = Comment (Mood, [CommentPart])+ deriving (Eq, Show, Generic)++instance Out Comment++newtype Judgement+ = Judgement (Header, [Comment], [Judgement])+ deriving (Eq, Show, Generic)++instance Out Judgement
+ src/Parser.hs view
@@ -0,0 +1,11 @@+module Parser+ ( ParseError+ , parseString+ , parseFile+ ) where++import Parser.Impl+ ( ParseError+ , parseString+ , parseFile+ )
+ src/Parser/Impl.hs view
@@ -0,0 +1,127 @@+{-# LANGUAGE DeriveGeneric #-}++module Parser.Impl where++import Ast+import Text.ParserCombinators.ReadP+import Text.PrettyPrint.GenericPretty++import Control.Monad ( void )+import Data.Maybe ( listToMaybe )++data ParseErrorImpl a+ = NoParse+ | AmbiguousGrammar [a]+ | NotImplemented+ deriving (Eq, Show, Generic)++instance (Out a) => Out (ParseErrorImpl a)++type ParseError = ParseErrorImpl [Judgement]++parseIntegral :: ReadP String+parseIntegral = munch1 (`elem` ['0'..'9'])++-- Source: http://hackage.haskell.org/package/cgi-3001.3.0.2/docs/src/Network-CGI-Protocol.html#maybeRead+maybeRead :: Read a => String -> Maybe a+maybeRead = fmap fst . listToMaybe . reads++parsePoints :: ReadP Double+parsePoints = do+ is <- parseIntegral+ fs <- (char '.' *> parseIntegral) +++ pure "0"+ case (maybeRead (is ++ "." ++ fs)) of+ Just x -> pure x+ _ -> pfail++lineToken :: ReadP a -> ReadP a+lineToken p = munch (`elem` [' ', '\t', '\r', '\v', '\f']) *> p++lineBreak :: ReadP ()+lineBreak = void $ lineToken $ char '\n'++munchTillExcl :: Char -> ReadP String+munchTillExcl c = munch (/= c) <* char c++parseHeader :: Int -> ReadP Header+parseHeader depth = do+ let mark = take depth $ repeat '#'+ void $ string mark+ void $ char ' '+ title <- lineToken $ munchTillExcl ':'++ points <- lineToken $ parsePoints+ void $ lineToken $ char '/'+ maxPoints <- lineToken $ parsePoints++ void $ lineBreak++ pure $ Header (title, points, maxPoints)++parseMood :: ReadP Mood+parseMood = choice+ [ char '+' *> pure Positive+ , char '-' *> pure Negative+ , char '*' *> pure Neutral+ , char '?' *> pure Impartial+ ]++parseLine :: ReadP String+parseLine = munchTillExcl '\n'++parseLines :: String -> ReadP [String]+parseLines indent = many $ string indent *> parseLine++parseCommentPart :: String -> ReadP CommentPart+parseCommentPart indent = many lineBreak *> do+ void $ string indent+ (fmap CommentCmt $ parseComment indent) <+++ (fmap CommentStr parseLine)++parseComment :: String -> ReadP Comment+parseComment indent = do+ mood <- parseMood+ void $ char ' '+ first <- fmap CommentStr parseLine+ rest <- many $ parseCommentPart (indent ++ " ")+ pure $ Comment (mood, (first : rest))++parseComment' :: ReadP Comment+parseComment' = many lineBreak *> do+ void $ string " "+ parseComment " "++parseJudgement :: Int -> ReadP Judgement+parseJudgement depth = skipSpaces *> do+ header <- parseHeader depth+ comments <- many parseComment'+ subjs <- many $ parseJudgement (depth + 1)+ pure $ Judgement (header, comments, subjs)++parseJudgements :: Int -> ReadP [Judgement]+parseJudgements depth = many $ parseJudgement depth++parse :: ReadP a -> String -> [(a, String)]+parse = readP_to_S++fullParse :: ReadP a -> String -> [a]+fullParse p s = fmap fst $ parse (p <* (skipSpaces >> eof)) s++parseString' :: ReadP a -> String -> Either (ParseErrorImpl a) a+parseString' p s =+ case fullParse p s of+ [] -> Left NoParse+ [a] -> Right a+ as -> Left $ AmbiguousGrammar as++parseEntry :: ReadP [Judgement]+parseEntry = parseJudgements 1 <* skipSpaces++parseString :: String -> Either ParseError [Judgement]+parseString = parseString' parseEntry++parseFile' :: ReadP a -> FilePath -> IO (Either (ParseErrorImpl a) a)+parseFile' p path = fmap (parseString' p) $ readFile path++parseFile :: FilePath -> IO (Either ParseError [Judgement])+parseFile = parseFile' parseEntry
+ src/Validator.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE DeriveGeneric #-}++module Validator ( validate, Invalid(..) ) where++import Ast++import Control.Monad ( forM_ )++import Text.PrettyPrint.GenericPretty++data Invalid+ = PointsExceedMaxPoints Header+ | BadSubJudgementPointsSum Judgement+ | BadSubJudgementMaxPointsSum Judgement+ deriving (Eq, Show, Generic)++instance Out Invalid++try :: Bool -> Invalid -> Either Invalid ()+try True = \_ -> Right ()+try False = Left++infix 4 ~=+(~=) :: Double -> Double -> Bool+x ~= y = abs (x - y) <= 0.01++validate :: Judgement -> Either Invalid ()+validate j @ (Judgement (h @ (Header (_, p, maxP)), _, subjs)) = do+ try (p <= maxP)+ (PointsExceedMaxPoints h)+ try ((sum $ map points subjs) ~= p)+ (BadSubJudgementPointsSum j)+ try ((sum $ map maxPoints subjs) ~= maxP)+ (BadSubJudgementMaxPointsSum j)+ forM_ subjs validate++points :: Judgement -> Double+points (Judgement (Header (_, v, _), _, _)) = v++maxPoints :: Judgement -> Double+maxPoints (Judgement (Header (_, _, v), _, _)) = v
+ test/Parser/BlackBoxTests.hs view
@@ -0,0 +1,69 @@+{-# LANGUAGE DeriveGeneric #-}++module Parser.BlackBoxTests ( allTests ) where++import Ast+import Parser++import Test.Tasty+import Test.Tasty.HUnit+-- import Test.Tasty.QuickCheck+import Test.Tasty.Golden++import Text.PrettyPrint.GenericPretty++unitTests :: TestTree+unitTests = testGroup "Unit tests"+ [ testCase "Lone header line" $+ parseString "# A: 0/0\n" @?=+ Right+ [ Judgement (Header ("A",0.0,0.0), [], [])+ ]+ , testCase "A couple same-depth header lines" $+ parseString "# A: 0/0\n# B: 0/0\n" @?=+ Right+ [ Judgement (Header ("A",0.0,0.0), [], [])+ , Judgement (Header ("B",0.0,0.0), [], [])+ ]+ , testCase "A simple hierarchy of headers" $+ parseString "# A: 0/0\n## B: 0/0\n" @?=+ Right+ [ Judgement (Header ("A",0.0,0.0), [],+ [ Judgement (Header ("B",0.0,0.0), [], [])+ ])+ ]+ , testCase "A couple simple hierarchies" $+ parseString "# A: 0/0\n## B: 0/0\n# C: 0/0\n" @?=+ Right+ [ Judgement (Header ("A",0.0,0.0), [],+ [ Judgement (Header ("B",0.0,0.0), [], [])+ ])+ , Judgement (Header ("C",0.0,0.0), [], [])+ ]+ ]++qcTests :: TestTree+qcTests = testGroup "QuickCheck tests" []++golden' :: String -> TestTree+golden' basename =+ goldenVsFile name fref fout $ do+ p <- parseFile fin+ writeFile fout $ (pretty p) ++ "\n"+ where+ prefix = "test/golden/"+ name = basename ++ ".mrk"+ fin = prefix ++ name+ fref = prefix ++ basename ++ ".ref"+ fout = prefix ++ basename ++ ".out"++goldenTests :: TestTree+goldenTests = testGroup "Golden tests"+ [ golden' "deep-judgement"+ , golden' "some-top-level-judgements"+ , golden' "some-judgements"+ ]++allTests :: TestTree+allTests = testGroup "Black-box parser tests"+ [ unitTests, qcTests, goldenTests ]
+ test/Tests.hs view
@@ -0,0 +1,10 @@+module Main where++import Test.Tasty++import qualified Parser.BlackBoxTests as PBBT+import qualified ValidatorTests as VT++main :: IO ()+main = defaultMain $ testGroup "All Tests"+ [ PBBT.allTests, VT.allTests ]
+ test/ValidatorTests.hs view
@@ -0,0 +1,63 @@+{-# LANGUAGE DeriveGeneric #-}++module ValidatorTests ( allTests ) where++import Ast+import Parser+import Validator++import Test.Tasty+import Test.Tasty.HUnit+-- import Test.Tasty.QuickCheck+import Test.Tasty.Golden++import Text.PrettyPrint.GenericPretty++validateStr :: String -> [Either Invalid ()]+validateStr s =+ case parseString s of+ Right js -> map validate js+ Left e -> []++posUnitTests :: TestTree+posUnitTests = testGroup "Positive Unit Tests"+ [ testCase "Lone header line" $+ validateStr "# A: 0/0\n" @?=+ [Right ()]+ , testCase "A couple same-depth header lines" $+ validateStr "# A: 0/0\n# B: 0/0\n" @?=+ [Right (), Right ()]+ , testCase "A simple hierarchy of headers" $+ validateStr "# A: 0/0\n## B: 0/0\n" @?=+ [Right ()]+ , testCase "A couple simple hierarchies" $+ validateStr "# A: 0/0\n## B: 0/0\n# C: 0/0\n" @?=+ [Right (), Right ()]+ ]++negUnitTests :: TestTree+negUnitTests = testGroup "Positive Unit Tests"+ [ testCase "Points exceed max points" $+ validateStr "# A: 1/0\n" @?=+ [Left $ PointsExceedMaxPoints (Header ("A", 1.0, 0.0))]+ , testCase "Sub-judgement points don't sum up to points" $+ validateStr "# A: 0/0\n## B: 1/0\n" @?=+ [Left $ BadSubJudgementPointsSum+ (Judgement (Header ("A", 0.0, 0.0), [],+ [Judgement (Header ("B", 1.0, 0.0), [], [])]))]+ , testCase "Sub-judgement max-points don't sum up to max-points" $+ validateStr "# A: 0/0\n## B: 0/1\n" @?=+ [Left $ BadSubJudgementMaxPointsSum+ (Judgement (Header ("A", 0.0, 0.0), [],+ [Judgement (Header ("B", 0.0, 1.0), [], [])]))]+ ]++qcTests :: TestTree+qcTests = testGroup "QuickCheck tests" []++goldenTests :: TestTree+goldenTests = testGroup "Golden tests" []++allTests :: TestTree+allTests = testGroup "Validator tests"+ [ posUnitTests, negUnitTests, qcTests, goldenTests ]