Skip to content

Commit 6d528a8

Browse files
committed
Add tasty test-suite for pure helpers.
The cabal file is restructured into a library plus executable plus test-suite, sharing a `common opts` stanza for default extensions and GHC options. The library exposes every module except `Main` so the test-suite can import them directly. Initial tests cover the pure logic that is easiest to nail down: * `Lexer.doP`/`doF`/`doS`/`doI` for the mpg123 protocol parsers, * `Tree.doOrphans` and `Tree.merge` (including the value-order behavior of the latter), * `FastIO.basenameP`/`dirnameP`/`trim`/`packedFileNameEndClean`, * `Core.showTimeDiff_` across the four output branches. `Lexer.doP/doF/doS/doI` and `Core.showTimeDiff_` are added to their module export lists for testability. CI runs `cabal build all && cabal test all` and `stack test`; the build step is kept because `cabal test` does not build the executable.
1 parent d02ef19 commit 6d528a8

9 files changed

Lines changed: 290 additions & 23 deletions

File tree

.github/workflows/haskell.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ jobs:
1212
- uses: haskell-actions/setup@v2
1313
with:
1414
ghc-version: ${{ matrix.ghc }}
15-
- run: cabal build
15+
- run: cabal build all
16+
- run: cabal test all --test-show-details=streaming
1617
stack:
1718
runs-on: 'ubuntu-22.04'
1819
name: Stack
@@ -21,5 +22,4 @@ jobs:
2122
- uses: haskell-actions/setup@v2
2223
with:
2324
enable-stack: true
24-
- run: stack build
25-
25+
- run: stack test

Core.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ module Core (
3838
loadConfig,
3939
discardErrors,
4040
toggleExit,
41+
showTimeDiff_,
4142
) where
4243

4344
import Base

Lexer.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
-- Lexer for mpg123 messages
2222

23-
module Lexer ( parser ) where
23+
module Lexer ( parser, doP, doF, doS, doI ) where
2424

2525
import Base
2626

hmp3-ng.cabal

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,26 @@ source-repository head
2323
type: git
2424
location: https://github.com/galenhuntington/hmp3-ng
2525

26-
executable hmp3
27-
main-is: Main.hs
28-
other-modules:
26+
common opts
27+
default-language: Haskell2010
28+
default-extensions:
29+
BangPatterns
30+
BlockArguments
31+
OverloadedStrings
32+
ScopedTypeVariables
33+
TypeApplications
34+
DerivingStrategies
35+
RecordWildCards
36+
LambdaCase
37+
MultiWayIf
38+
StandaloneDeriving
39+
NumericUnderscores
40+
ghc-options: -Wall -funbox-strict-fields -Wno-unused-do-bind
41+
42+
library
43+
import: opts
44+
hs-source-dirs: ./
45+
exposed-modules:
2946
Base
3047
Config
3148
Core
@@ -38,24 +55,10 @@ executable hmp3
3855
Syntax
3956
Tree
4057
UI
58+
other-modules:
4159
Paths_hmp3_ng
4260
autogen-modules:
4361
Paths_hmp3_ng
44-
hs-source-dirs:
45-
./
46-
default-extensions:
47-
BangPatterns
48-
BlockArguments
49-
OverloadedStrings
50-
ScopedTypeVariables
51-
TypeApplications
52-
DerivingStrategies
53-
RecordWildCards
54-
LambdaCase
55-
MultiWayIf
56-
StandaloneDeriving
57-
NumericUnderscores
58-
ghc-options: -Wall -funbox-strict-fields -threaded -Wno-unused-do-bind
5962
pkgconfig-depends:
6063
ncursesw
6164
build-depends:
@@ -73,5 +76,33 @@ executable hmp3
7376
, random
7477
, unix >=2.7
7578
, utf8-string
76-
default-language: Haskell2010
7779

80+
executable hmp3
81+
import: opts
82+
main-is: Main.hs
83+
hs-source-dirs: ./
84+
ghc-options: -threaded
85+
build-depends:
86+
, base
87+
, bytestring
88+
, hmp3-ng
89+
, unix
90+
, utf8-string
91+
92+
test-suite test
93+
import: opts
94+
type: exitcode-stdio-1.0
95+
main-is: Main.hs
96+
hs-source-dirs: test
97+
other-modules:
98+
CoreSpec
99+
FastIOSpec
100+
LexerSpec
101+
TreeSpec
102+
build-depends:
103+
, base
104+
, bytestring
105+
, clock
106+
, hmp3-ng
107+
, tasty >=1.4
108+
, tasty-hunit >=0.10

test/CoreSpec.hs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
module CoreSpec (tests) where
2+
3+
import Test.Tasty
4+
import Test.Tasty.HUnit
5+
6+
import System.Clock (TimeSpec(..))
7+
8+
import Core (showTimeDiff_)
9+
10+
tests :: TestTree
11+
tests = testGroup "Core"
12+
[ testGroup "showTimeDiff_ (secs=False)"
13+
[ testCase "under a minute is 0m"
14+
$ showTimeDiff_ False (t 0) (t 30) @?= "0m"
15+
, testCase "exactly one minute"
16+
$ showTimeDiff_ False (t 0) (t 60) @?= "1m"
17+
, testCase "under an hour"
18+
$ showTimeDiff_ False (t 0) (t 599) @?= "9m"
19+
, testCase "exactly one hour"
20+
$ showTimeDiff_ False (t 0) (t 3600) @?= "1h00m"
21+
, testCase "one hour, one minute, one second drops seconds"
22+
$ showTimeDiff_ False (t 0) (t 3661) @?= "1h01m"
23+
, testCase "exactly one day"
24+
$ showTimeDiff_ False (t 0) (t 86400) @?= "1d00h00m"
25+
, testCase "day, hour, minute"
26+
$ showTimeDiff_ False (t 0) (t 90060) @?= "1d01h01m"
27+
]
28+
, testGroup "showTimeDiff_ (secs=True)"
29+
[ testCase "thirty seconds"
30+
$ showTimeDiff_ True (t 0) (t 30) @?= "30s"
31+
, testCase "one minute exactly appends 00s"
32+
$ showTimeDiff_ True (t 0) (t 60) @?= "1m00s"
33+
, testCase "one minute thirty seconds"
34+
$ showTimeDiff_ True (t 0) (t 90) @?= "1m30s"
35+
, testCase "one hour one minute one second"
36+
$ showTimeDiff_ True (t 0) (t 3661) @?= "1h01m01s"
37+
, testCase "diff is taken from monotonic delta, not absolute values"
38+
$ showTimeDiff_ True (t 1000) (t 1090) @?= "1m30s"
39+
]
40+
]
41+
42+
-- Build a TimeSpec from a whole number of seconds.
43+
t :: Integer -> TimeSpec
44+
t s = TimeSpec (fromInteger s) 0

test/FastIOSpec.hs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
module FastIOSpec (tests) where
2+
3+
import Test.Tasty
4+
import Test.Tasty.HUnit
5+
6+
import FastIO (basenameP, dirnameP, packedFileNameEndClean, trim)
7+
8+
tests :: TestTree
9+
tests = testGroup "FastIO"
10+
[ testGroup "basenameP"
11+
[ testCase "no slash" $ basenameP "foo" @?= "foo"
12+
, testCase "single dir" $ basenameP "foo/bar" @?= "bar"
13+
, testCase "leading slash" $ basenameP "/foo" @?= "foo"
14+
, testCase "nested" $ basenameP "a/b/c/d.mp3" @?= "d.mp3"
15+
, testCase "trailing slash" $ basenameP "foo/" @?= ""
16+
, testCase "empty" $ basenameP "" @?= ""
17+
]
18+
, testGroup "dirnameP"
19+
[ testCase "no slash" $ dirnameP "foo" @?= "."
20+
, testCase "single dir" $ dirnameP "foo/bar" @?= "foo"
21+
, testCase "leading slash" $ dirnameP "/foo" @?= ""
22+
, testCase "nested" $ dirnameP "a/b/c/d.mp3" @?= "a/b/c"
23+
]
24+
, testGroup "trim"
25+
[ testCase "no whitespace" $ trim "foo" @?= "foo"
26+
, testCase "leading spaces" $ trim " foo" @?= "foo"
27+
, testCase "trailing spaces" $ trim "foo " @?= "foo"
28+
, testCase "both" $ trim " foo " @?= "foo"
29+
, testCase "internal preserved" $ trim " foo bar " @?= "foo bar"
30+
, testCase "tabs and newlines" $ trim "\t foo \n" @?= "foo"
31+
, testCase "whitespace only" $ trim " " @?= ""
32+
, testCase "empty" $ trim "" @?= ""
33+
]
34+
, testGroup "packedFileNameEndClean"
35+
[ testCase "no trailing" $ packedFileNameEndClean "foo" @?= "foo"
36+
, testCase "trailing slash" $ packedFileNameEndClean "foo/" @?= "foo"
37+
, testCase "trailing backslash" $ packedFileNameEndClean "foo\\" @?= "foo"
38+
, testCase "multiple trailing" $ packedFileNameEndClean "foo/\\/" @?= "foo"
39+
, testCase "internal preserved" $ packedFileNameEndClean "a/b/c/" @?= "a/b/c"
40+
, testCase "empty" $ packedFileNameEndClean "" @?= ""
41+
]
42+
]

test/LexerSpec.hs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
module LexerSpec (tests) where
2+
3+
import Test.Tasty
4+
import Test.Tasty.HUnit
5+
6+
import qualified Data.ByteString.Char8 as P
7+
8+
import Lexer (doP, doF, doS, doI)
9+
import Syntax
10+
11+
tests :: TestTree
12+
tests = testGroup "Lexer"
13+
[ testGroup "doP (status messages)"
14+
[ testCase "0 is Stopped" $ status (doP "0") @?= Stopped
15+
, testCase "1 is Paused" $ status (doP "1") @?= Paused
16+
, testCase "2 is Playing" $ status (doP "2") @?= Playing
17+
, testCase "3 is Playing" $ status (doP "3") @?= Playing
18+
, testCase "empty is Playing" $ status (doP "") @?= Playing
19+
, testCase "garbage is Playing" $ status (doP "xyz") @?= Playing
20+
]
21+
, testGroup "doF (frame messages)"
22+
[ testCase "all four fields parse" $ do
23+
let Frame{..} = frame (doF "123 456 12.34 56.78")
24+
currentFrame @?= 123
25+
framesLeft @?= 456
26+
currentTime @?= 12.34
27+
timeLeft @?= 56.78
28+
, testCase "negative timeLeft is clamped to zero" $ do
29+
timeLeft (frame (doF "0 0 0.00 -1.00")) @?= 0
30+
]
31+
, testGroup "doS (info messages)"
32+
[ testCase "userinfo combines version, bitrate, kHz" $
33+
userinfo (info (doS "1.0 1 44100 stereo 0 0 2 0 0 0 128 0"))
34+
@?= "mpeg 1.0 128kbit/s 44kHz"
35+
]
36+
, testGroup "doI (track info / id3)"
37+
[ testCase "non-id3 input becomes Left filename" $
38+
fileLeft (doI "song.mp3") @?= "song.mp3"
39+
, testCase "non-id3 input is trimmed" $
40+
fileLeft (doI " song.mp3 ") @?= "song.mp3"
41+
, testCase "id3 with title only" $ do
42+
let i = fileRight (doI ("ID3:" <> field30 "Title"))
43+
id3title i @?= "Title"
44+
id3artist i @?= ""
45+
id3album i @?= ""
46+
id3str i @?= "Title"
47+
, testCase "id3 with title and artist" $ do
48+
let i = fileRight (doI ("ID3:" <> field30 "Title" <> field30 "Artist"))
49+
id3title i @?= "Title"
50+
id3artist i @?= "Artist"
51+
id3album i @?= ""
52+
id3str i @?= "Artist : Title"
53+
, testCase "id3 with title, artist, and album" $ do
54+
let i = fileRight (doI ("ID3:" <> field30 "Title"
55+
<> field30 "Artist"
56+
<> field30 "Album"))
57+
id3title i @?= "Title"
58+
id3artist i @?= "Artist"
59+
id3album i @?= "Album"
60+
id3str i @?= "Artist : Album : Title"
61+
, testCase "id3 with empty title falls back to Left of trimmed input" $
62+
-- mpg123 sometimes returns ID3 records with a blank title; rather than
63+
-- present an empty track name, the parser exposes the raw line.
64+
fileLeft (doI ("ID3:" <> field30 "" <> field30 "Artist"))
65+
@?= "ID3:" <> P.replicate 30 ' ' <> "Artist"
66+
]
67+
]
68+
69+
------------------------------------------------------------------------
70+
-- Helpers
71+
72+
-- Pad/truncate a ByteString to exactly 30 characters with trailing spaces,
73+
-- matching the fixed-width field convention used by mpg123 ID3 output.
74+
field30 :: P.ByteString -> P.ByteString
75+
field30 b = P.take 30 (b <> P.replicate 30 ' ')
76+
77+
status :: Msg -> Status
78+
status (S s) = s
79+
status _ = error "expected S"
80+
81+
frame :: Msg -> Frame
82+
frame (R f) = f
83+
frame _ = error "expected R"
84+
85+
info :: Msg -> Info
86+
info (I i) = i
87+
info _ = error "expected I"
88+
89+
fileLeft :: Msg -> P.ByteString
90+
fileLeft (F (File (Left s))) = s
91+
fileLeft _ = error "expected F (File (Left _))"
92+
93+
fileRight :: Msg -> Id3
94+
fileRight (F (File (Right i))) = i
95+
fileRight _ = error "expected F (File (Right _))"

test/Main.hs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module Main (main) where
2+
3+
import Test.Tasty
4+
5+
import qualified CoreSpec
6+
import qualified FastIOSpec
7+
import qualified LexerSpec
8+
import qualified TreeSpec
9+
10+
main :: IO ()
11+
main = defaultMain $ testGroup "hmp3-ng"
12+
[ CoreSpec.tests
13+
, FastIOSpec.tests
14+
, LexerSpec.tests
15+
, TreeSpec.tests
16+
]

test/TreeSpec.hs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
module TreeSpec (tests) where
2+
3+
import Test.Tasty
4+
import Test.Tasty.HUnit
5+
6+
import Tree (doOrphans, merge)
7+
8+
tests :: TestTree
9+
tests = testGroup "Tree"
10+
[ testGroup "doOrphans"
11+
[ testCase "empty"
12+
$ doOrphans [] @?= []
13+
, testCase "bare filename"
14+
$ doOrphans ["song.mp3"] @?= [(".", ["song.mp3"])]
15+
, testCase "single directory"
16+
$ doOrphans ["a/song.mp3"] @?= [("a", ["song.mp3"])]
17+
, testCase "nested directory"
18+
$ doOrphans ["a/b/song.mp3"] @?= [("a/b", ["song.mp3"])]
19+
, testCase "multiple, no merging here"
20+
$ doOrphans ["a/x.mp3", "a/y.mp3"] @?= [("a", ["x.mp3"]), ("a", ["y.mp3"])]
21+
]
22+
, testGroup "merge"
23+
[ testCase "empty"
24+
$ merge [] @?= []
25+
, testCase "singleton passes through"
26+
$ merge [("a", ["x"])] @?= [("a", ["x"])]
27+
, testCase "different keys are sorted"
28+
$ merge [("b", ["1"]), ("a", ["2"])] @?= [("a", ["2"]), ("b", ["1"])]
29+
, testCase "same key combines values"
30+
$ merge [("a", ["x"]), ("a", ["y"])] @?= [("a", ["x", "y"])]
31+
, testCase "preserves value order within a key"
32+
$ merge [("a", ["1"]), ("a", ["2"]), ("a", ["3"])] @?= [("a", ["1", "2", "3"])]
33+
, testCase "value lists with multiple elements"
34+
$ merge [("a", ["x", "y"]), ("a", ["z"])] @?= [("a", ["x", "y", "z"])]
35+
, testCase "interleaved keys"
36+
$ merge [("a", ["1"]), ("b", ["2"]), ("a", ["3"])] @?= [("a", ["1", "3"]), ("b", ["2"])]
37+
]
38+
]

0 commit comments

Comments
 (0)