module LibSpec where
import Protolude (
Bool (True),
ExitCode (ExitFailure),
Maybe (..),
Text,
isJust,
pure,
show,
($),
(&),
(/=),
(<),
(<>),
(==),
)
import Protolude qualified as P
import Control.Arrow ((>>>))
import Data.Aeson (decode)
import Data.List.Utils (subIndex)
import Data.Text (unpack)
import Data.Text qualified as T
import Data.Time.ISO8601.Duration qualified as Iso
import Database.SQLite.Simple (SQLData (SQLNull), query_)
import Test.Hspec (
Spec,
context,
it,
shouldBe,
shouldContain,
shouldEndWith,
shouldNotBe,
shouldNotContain,
shouldNotSatisfy,
shouldSatisfy,
shouldStartWith,
shouldThrow,
)
import Config (defaultConfig)
import Data.Hourglass (TimeFormat (..), timePrint)
import FullTask (FullTask, emptyFullTask)
import FullTask qualified
import ImportExport (EditMode (ApplyPreEdit), editTaskByTask)
import Lib (
addNote,
addTag,
addTask,
countTasks,
deleteNote,
deleteTag,
deleteTasks,
doTasks,
duplicateTasks,
headTasks,
infoTask,
insertRecord,
insertTags,
listNotes,
logTask,
newTasks,
nextTask,
recurTasks,
repeatTasks,
runFilter,
setDueUtc,
setReadyUtc,
unrepeatTasks,
updateTask,
)
import Note (Note)
import System.Hourglass (dateCurrent)
import Task (
Task (
body,
closed_utc,
due_utc,
group_ulid,
metadata,
modified_utc,
ready_utc,
repetition_duration,
state,
ulid,
user
),
TaskState (Done),
emptyTask,
)
import TaskToNote (TaskToNote (TaskToNote, ulid))
import TaskToNote qualified
import TaskToTag (TaskToTag)
import TaskToTag qualified
import TestUtils (withMemoryDb)
import Utils (parseUtc, zeroTime, zeroUlidTxt)
exampleTask :: Task
exampleTask =
emptyTask
{ Task.ulid = "01hq68smfe0r9entg3x4rb9441"
, Task.body = "Buy milk"
, Task.state = Nothing
, Task.modified_utc = "2024-02-21 16:43:17"
, Task.due_utc = Just "2025-07-08 10:22:56"
, Task.user = "john_vdg1c2hz"
, Task.metadata = "{\"source\":\"fridge\"}" & decode
}
task1 :: Task
task1 =
emptyTask
{ Task.ulid = "01hs68z7mdg4ktpxbv0yfafznq"
, Task.body = "New task 1"
, Task.modified_utc = "2024-03-17 13:17:44.461"
}
taskMultiLine :: Task
taskMultiLine =
emptyTask
{ Task.ulid = "01hx48cnjhp18mts3c44zk3gen"
, Task.body =
"New task\n\
\with several lines\n\
\and line breaks"
}
normTask :: Task -> Task
normTask task =
task
{ Task.ulid = ""
, Task.modified_utc = ""
, Task.due_utc = Nothing
, Task.user = ""
}
replaceBs :: P.ByteString -> P.ByteString -> P.ByteString -> P.ByteString
replaceBs needle replacement haystack = do
haystack
& P.decodeUtf8
& T.replace (P.decodeUtf8 needle) (P.decodeUtf8 replacement)
& P.encodeUtf8
spec :: Spec
spec = do
let
now = "2024-05-08 10:04:17" & parseUtc & P.fromMaybe zeroTime
conf = defaultConfig
it "initially contains no tasks" $ do
withMemoryDb conf $ \memConn -> do
tasks <- headTasks conf now memConn Nothing
unpack (show tasks) `shouldStartWith` "No tasks available"
it "inserts a task" $ do
withMemoryDb conf $ \memConn -> do
let task =
emptyTask
{ Task.ulid = "01hrvhc0h1pncbczxym16642mm"
, Task.body = "Directly inserted task"
, Task.state = Just Done
}
insertRecord "tasks" memConn task
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
tasks `shouldBe` [task]
it "adds a new task" $ do
withMemoryDb conf $ \memConn -> do
result <- addTask conf memConn ["Just a test"]
unpack (show result)
`shouldStartWith` "π Added task \"Just a test\" with id"
context "When a task exists" $ do
it "updates a task" $ do
withMemoryDb conf $ \memConn -> do
let initialTask =
emptyTask
{ Task.ulid = "01hrvhdddfwsrnp6dd8h7tp8h4"
, Task.body = "New task"
, Task.state = Just Done
}
newTask = initialTask{body = "Updated task"}
insertRecord "tasks" memConn initialTask
updateTask memConn newTask
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
case tasks of
[updatedTask] -> do
-- Task should have a different `modified_utc` value
updatedTask `shouldSatisfy` (\task -> task.modified_utc /= "")
updatedTask{modified_utc = ""} `shouldBe` newTask
_ ->
P.die "Found more than one task"
it "lists next task" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
result <- nextTask conf memConn
unpack (show result) `shouldContain` "Buy milk"
it "adds a tag" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
tagResult <- addTag conf memConn "test" [exampleTask.ulid]
unpack (show tagResult)
`shouldStartWith` "π· Added tag \"test\" to task"
taskToTags :: [TaskToTag] <-
query_ memConn "SELECT * FROM task_to_tag"
case taskToTags of
[taskToTag] -> do
taskToTag.ulid `shouldNotBe` ""
taskToTag.task_ulid `shouldNotBe` ""
taskToTag.tag `shouldBe` "test"
_ -> P.die "More than one task_to_tag row found"
it "strips leading + when adding a tag" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
tagResult <- addTag conf memConn "+test" [exampleTask.ulid]
unpack (show tagResult)
`shouldStartWith` "π· Added tag \"test\" to task"
taskToTags :: [TaskToTag] <-
query_ memConn "SELECT * FROM task_to_tag"
case taskToTags of
[taskToTag] -> taskToTag.tag `shouldBe` "test"
_ -> P.die "More than one task_to_tag row found"
it "deletes a tag" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
_ <- addTag conf memConn "test" [exampleTask.ulid]
delResult <- deleteTag conf memConn "test" [exampleTask.ulid]
unpack (show delResult)
`shouldStartWith` "π₯ Removed tag \"test\" of task"
taskToTags :: [TaskToTag] <-
query_ memConn "SELECT * FROM task_to_tag"
taskToTags `shouldBe` []
it "doesn't delete a tag that does not exist" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
delResult <- deleteTag conf memConn "test" [exampleTask.ulid]
unpack (show delResult) `shouldContain` "not set"
it "adds a note" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
noteResult <- addNote conf memConn "A test note" [exampleTask.ulid]
unpack (show noteResult)
`shouldStartWith` "π Added a note to task"
-- it "correctly wraps multiline notes" $ do
-- See error:
-- tl info x795vqf
it "sets due UTC" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
let utcTxt = "2087-03-21 17:43:00"
case parseUtc utcTxt of
Nothing -> P.die "Invalid UTC string"
Just utcStamp -> do
result <- setDueUtc conf memConn utcStamp [exampleTask.ulid]
unpack (show result)
`shouldStartWith` ( "π
Set due UTC of task \""
<> T.unpack exampleTask.body
<> "\" with id \""
<> T.unpack exampleTask.ulid
<> "\" to \""
<> T.unpack utcTxt
<> "\""
)
it "sets ready UTC" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
let utcTxt = "2059-07-11 04:55:16"
case parseUtc utcTxt of
Nothing -> P.die "Invalid UTC string"
Just utcStamp -> do
result <- setReadyUtc conf memConn utcStamp [exampleTask.ulid]
unpack (show result)
`shouldStartWith` ( "π
Set ready UTC of task \""
<> T.unpack exampleTask.body
<> "\" with id \""
<> T.unpack exampleTask.ulid
<> "\" to \""
<> T.unpack utcTxt
<> "\""
)
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
case tasks of
[updatedTask] -> do
updatedTask `shouldSatisfy` (\task -> isJust task.ready_utc)
_ -> P.die "Found more than one task"
it "completes it" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
doResult <- doTasks conf memConn Nothing [exampleTask.ulid]
unpack (show doResult) `shouldStartWith` "β
Finished task"
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
case tasks of
[updatedTask] -> do
updatedTask `shouldSatisfy` (\task -> task.state == Just Done)
updatedTask `shouldSatisfy` (\task -> isJust task.closed_utc)
_ -> P.die "Found more than one task"
it "deletes it" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
deleteResult <- deleteTasks conf memConn [exampleTask.ulid]
unpack (show deleteResult) `shouldStartWith` "β Deleted task"
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
tasks `shouldBe` []
tags :: [TaskToTag] <- query_ memConn "SELECT * FROM task_to_tag"
tags `shouldBe` []
notes :: [Note] <- query_ memConn "SELECT * FROM task_to_note"
notes `shouldBe` []
it "duplicates a task" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
duplicationResult <- duplicateTasks conf memConn [exampleTask.ulid]
unpack (show duplicationResult)
`shouldStartWith` "π― Created a duplicate of task \"Buy milk\""
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
case tasks of
[taskA, taskB] -> do
normTask taskA `shouldBe` normTask exampleTask
normTask taskB `shouldBe` normTask exampleTask
_ -> P.die "Must have exactly two tasks"
it "deletes obsolete fields on duplication" $ do
withMemoryDb conf $ \memConn -> do
insertRecord "tasks" memConn exampleTask
let
zeroDur = Iso.DurationWeek (Iso.DurWeek 0)
oneMonth = "P1M" & Iso.parseDuration & P.fromRight zeroDur
_ <- repeatTasks conf memConn oneMonth [exampleTask.ulid]
_ <- duplicateTasks conf memConn [exampleTask.ulid]
tasks :: [Task] <-
query_ memConn "SELECT * FROM tasks ORDER BY ulid DESC"
case tasks of
[dupe, original] -> do
original.group_ulid `shouldSatisfy` P.isJust
original.repetition_duration `shouldSatisfy` P.isJust
dupe.group_ulid `shouldSatisfy` P.isNothing
dupe.repetition_duration `shouldSatisfy` P.isNothing
dupe.user `shouldNotBe` original.user
normTask dupe `shouldBe` normTask exampleTask
_ -> P.die "Must have exactly two tasks"
it "adds a task with tags and due date" $ do
withMemoryDb conf $ \memConn -> do
_ <- addTask conf memConn ["Just a test +tag due:2082-10-03 +what"]
(tasks :: [FullTask]) <- query_ memConn "SELECT * FROM tasks_view"
case tasks of
[updatedTask] -> do
updatedTask `shouldSatisfy` (\task -> task.ulid /= "")
updatedTask `shouldSatisfy` (\task -> task.modified_utc /= "")
updatedTask `shouldSatisfy` (\task -> task.user /= "")
updatedTask
{ FullTask.ulid = ""
, FullTask.modified_utc = ""
, FullTask.user = ""
}
`shouldBe` emptyFullTask
{ FullTask.body = "Just a test"
, FullTask.due_utc = Just "2082-10-03 00:00:00"
, FullTask.priority = Just 2.0
, FullTask.tags = Just ["tag", "what"]
}
_ -> P.die "Found more than one task"
it "deduplicates tags when adding a task" $ do
withMemoryDb conf $ \memConn -> do
_ <- addTask conf memConn ["Buy milk +drink +drink"]
(tasks :: [FullTask]) <- query_ memConn "SELECT * FROM tasks_view"
case tasks of
[updatedTask] -> do
updatedTask `shouldSatisfy` (\task -> task.ulid /= "")
updatedTask `shouldSatisfy` (\task -> task.modified_utc /= "")
updatedTask `shouldSatisfy` (\task -> task.user /= "")
updatedTask
{ FullTask.ulid = ""
, FullTask.modified_utc = ""
, FullTask.user = ""
}
`shouldBe` emptyFullTask
{ FullTask.body = "Buy milk"
, FullTask.priority = Just 2.0
, FullTask.tags = Just ["drink"]
}
_ -> P.die "Found more than one task"
it "logs a task" $ do
withMemoryDb conf $ \memConn -> do
result <- logTask conf memConn ["Just a test"]
unpack (show result)
`shouldStartWith` "π Logged task \"Just a test\" with id"
it "dies on invalid filter expressions" $ do
withMemoryDb conf $ \memConn -> do
runFilter conf now memConn [" "] Nothing `shouldThrow` (== ExitFailure 1)
it "counts tasks" $ do
withMemoryDb defaultConfig $ \memConn -> do
let
task2 =
emptyTask
{ Task.ulid = "01hs690f9hkzk9z7zews9j2k1d"
, Task.body = "New task 2"
}
count0 <- countTasks defaultConfig memConn P.mempty
show count0 `shouldBe` ("0" :: Text)
insertRecord "tasks" memConn task1
count1 <- countTasks defaultConfig memConn P.mempty
show count1 `shouldBe` ("1" :: Text)
insertRecord "tasks" memConn task2
count2 <- countTasks defaultConfig memConn P.mempty
show count2 `shouldBe` ("2" :: Text)
warnings <- insertTags conf memConn Nothing task2 ["test"]
P.show warnings `shouldBe` T.empty
countWithTag <- countTasks defaultConfig memConn (Just ["+test"])
show countWithTag `shouldBe` ("1" :: Text)
pure ()
it "gets new tasks" $ do
withMemoryDb defaultConfig $ \memConn -> do
let
task2 =
emptyTask
{ Task.ulid = "01hs6zsf3c0vqx6egfnmbqtmvy"
, Task.body = "New task 2"
, Task.closed_utc = Just "2024-04-10T18:54:10Z"
, Task.state = Just Done
}
insertRecord "tasks" memConn task1
insertRecord "tasks" memConn task2
cliOutput <- newTasks defaultConfig now memConn (Just ["state:done"]) Nothing
show cliOutput `shouldContain` "New task 2"
show cliOutput `shouldNotContain` "New task 1"
it "shows warning if a tag is duplicated" $ do
withMemoryDb defaultConfig $ \memConn -> do
let newTag = "test"
insertRecord "tasks" memConn task1
warnings <- insertTags conf memConn Nothing task1 [newTag]
P.show warnings `shouldBe` T.empty
cliOutput <- addTag defaultConfig memConn newTag [task1.ulid]
show cliOutput `shouldEndWith` "Tag \"test\" is already assigned"
it "repeats a task and prevents recurring same task" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1
let oneWeek = Iso.DurationWeek (Iso.DurWeek 1)
repeatRes <- repeatTasks defaultConfig memConn oneWeek [task1.ulid]
P.show repeatRes `shouldContain` "Set repeat duration of task"
recurRes <- recurTasks defaultConfig memConn oneWeek [task1.ulid]
P.show recurRes `shouldContain` "is already in a repetition series"
unrepeatRes <- unrepeatTasks defaultConfig memConn [task1.ulid]
P.show unrepeatRes `shouldContain` "Removed repetition duration"
recurRes2 <- recurTasks defaultConfig memConn oneWeek [task1.ulid]
P.show recurRes2 `shouldContain` "Set recurrence duration of task"
repeatRes2 <- repeatTasks defaultConfig memConn oneWeek [task1.ulid]
P.show repeatRes2 `shouldContain` "is already in a recurrence series"
context "Editing a task" $ do
it "shows warning if a tag was duplicated" $ do
withMemoryDb defaultConfig $ \memConn -> do
let existTag = "existing-tag"
insertRecord "tasks" memConn task1
warnings <- insertTags conf memConn Nothing task1 [existTag]
P.show warnings `shouldBe` T.empty
cliOutput <-
editTaskByTask
conf
( ApplyPreEdit $
replaceBs
"\n...\n"
("\ntags: " <> P.show [existTag, "new-tag"] <> "\n...\n")
)
memConn
task1
let errMsg = "Tag \"" <> T.unpack existTag <> "\" is already assigned"
show cliOutput `shouldContain` errMsg
it "lets you change the closed_utc" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1
cliOutput <-
editTaskByTask
conf
( ApplyPreEdit $
replaceBs
"state: null"
"state: Done"
>>> replaceBs
"closed_utc: null"
"closed_utc: 2024-05-08 10:04"
)
memConn
task1
show cliOutput `shouldContain` "Edited task"
tasks :: [Task] <- query_ memConn "SELECT * FROM tasks"
case tasks of
[task] -> do
task.closed_utc `shouldBe` Just "2024-05-08 10:04:00.000"
task.state `shouldBe` Just Done
now_ <- dateCurrent
let today = now_ & timePrint (toFormat ("YYYY-MM-DD" :: [P.Char]))
T.unpack task.modified_utc `shouldStartWith` today
_ -> P.die "Found more than one task"
it "lets you add notes" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1
cliOutput <-
editTaskByTask
conf
( ApplyPreEdit $
replaceBs
"\n...\n"
("\nnotes: " <> P.show ["A short note" :: Text] <> "\n...\n")
)
memConn
task1
show cliOutput `shouldStartWith` "βοΈ Edited task \"New task 1\""
taskToNotes :: [TaskToNote] <-
query_ memConn "SELECT * FROM task_to_note"
case taskToNotes of
[taskToNote] -> do
taskToNote.ulid
`shouldNotSatisfy` (\ulid -> zeroUlidTxt `T.isPrefixOf` ulid)
taskToNote.note `shouldBe` "A short note"
_ -> P.die "Found more than one task_to_tag row"
it "de-duplicates notes" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1
let note = "A short note"
insertRecord
"task_to_note"
memConn
TaskToNote
{ TaskToNote.ulid = "01hxsjgzmdx48yzk39v852razr"
, TaskToNote.task_ulid = task1.ulid
, TaskToNote.note = note
}
taskInfo <- infoTask defaultConfig memConn task1.ulid
show taskInfo `shouldContain` T.unpack note
cliOutput <-
editTaskByTask
conf
( ApplyPreEdit $
replaceBs "notes: []" $
"notes: " <> P.show [note, note]
)
memConn
task1
show cliOutput `shouldStartWith` "βοΈ Edited task \"New task 1\""
taskToNotes :: [TaskToNote] <-
query_ memConn "SELECT * FROM task_to_note"
case taskToNotes of
[taskToNoteA, taskToNoteB] -> do
taskToNoteA.ulid
`shouldNotSatisfy` (\ulid -> zeroUlidTxt `T.isPrefixOf` ulid)
taskToNoteB.ulid
`shouldNotSatisfy` (\ulid -> zeroUlidTxt `T.isPrefixOf` ulid)
taskToNoteA.note `shouldBe` "A short note"
taskToNoteB.note `shouldBe` "A short note"
_ -> P.die "Found more than one task_to_tag row"
it "lets you set metadata to null" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1{metadata = Just "{\"a\":\"b\"}"}
cliOutput <-
editTaskByTask
conf
(ApplyPreEdit $ replaceBs "\n...\n" "\nmetadata: null\n...\n")
memConn
task1
show cliOutput `shouldStartWith` "βοΈ Edited task \"New task 1\""
tasks :: [[SQLData]] <- query_ memConn "SELECT metadata FROM tasks"
case tasks of
[[metadata]] -> do
metadata `shouldBe` SQLNull
_ -> P.die "Found more than one task"
it "only shows first line of multi-line tasks in task listing" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn taskMultiLine
tasks <- headTasks conf now memConn Nothing
show tasks `shouldContain` "New task βΌ"
show tasks `shouldNotContain` "with several lines"
it "keeps line breaks of multi-line tasks in info view" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn taskMultiLine
cliOutput <- infoTask defaultConfig memConn taskMultiLine.ulid
show cliOutput
`shouldContain` "New task\n\
\with several lines\n\
\and line breaks"
it "lists all notes descending by creation UTC" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1
let
taskToNote1 =
TaskToNote
{ TaskToNote.ulid = "01hx4eyxxvs5b75ynxrztcz87f"
, TaskToNote.task_ulid = task1.ulid
, TaskToNote.note = "The first note"
}
note1Id = taskToNote1.ulid & T.takeEnd 3 & T.unpack
taskToNote2 =
TaskToNote
{ TaskToNote.ulid = "01hx4f3f764sma7n8bahvwjeed"
, TaskToNote.task_ulid = task1.ulid
, TaskToNote.note = "The second note"
}
note2Id = taskToNote2.ulid & T.takeEnd 3 & T.unpack
insertRecord "task_to_note" memConn taskToNote1
insertRecord "task_to_note" memConn taskToNote2
cliOutput <- listNotes defaultConfig memConn
show cliOutput `shouldContain` note1Id
show cliOutput `shouldContain` note2Id
let
posUlid1 = subIndex note1Id (show cliOutput)
posUlid2 = subIndex note2Id (show cliOutput)
-- Newer notes should be listed first
(posUlid2 < posUlid1) `shouldBe` True
it "lets you delete a note" $ do
withMemoryDb defaultConfig $ \memConn -> do
insertRecord "tasks" memConn task1
let noteId = "01hwcqk9nnwjypzw9kr646nqce"
insertRecord
"task_to_note"
memConn
TaskToNote
{ TaskToNote.ulid = noteId
, TaskToNote.task_ulid = task1.ulid
, TaskToNote.note = "The note content"
}
cliOutput <- deleteNote defaultConfig memConn noteId
(show cliOutput :: Text)
`shouldBe` "\128165 Deleted note \"01hwcqk9nnwjypzw9kr646nqce\" \
\of task \"01hs68z7mdg4ktpxbv0yfafznq\""