Tutorial
Organize and test
Extract a module and test the logic behind your CLI.
Start with chapter 7's complete tool. It works, but commands, storage, and task logic now share one long file. We'll separate the reusable parts and put tests beside them. The command's behavior and your saved data will stay the same.
Split the file at a useful boundary
Create task_list.tea next to tasks.tea. Move the Task struct, TaskError, and the functions from format_task through complete_task into it. This module owns task data, its presentation, and its storage format. Keep help, usage, run, and the outer catch block in tasks.tea.
Move the std.string and std.parse imports into the new module. Both files need std.fs: the module performs file operations, and the entry file names fs.FsError in its error handlers.
Mark the declarations used by the command file with pub: Task, TaskError, show_tasks, load_tasks, save_tasks, add_task, and complete_task. Leave helpers such as encode_tasks private.
In tasks.tea, import the local module:
use task_list from "./task_list"This path is relative to the source file. Call exported functions through the alias, such as task_list.load_tasks("tasks.txt"), and qualify the error type as task_list.TaskError. The two complete files below show every changed call.
Write a test beside the logic
Add use assert from "std.assert" to task_list.tea, then append a test block:
test "format a task"
const task = Task(title: "Learn Tea", done: false)
assert.eq(format_task(task, 1), "1. [ ] Learn Tea")
endtest names a check that Tea's test runner can discover. assert.eq fails the test if the two values differ. Because this test lives in the module, it can call the private format_task helper.
Add the other three tests from the complete module. They check that encoding and decoding agree, completing a task mutates the original collection without changing its length, and malformed data is rejected. These tests use in-memory data, so running them doesn't touch your tasks.txt.
Run the tests
tea test "$PWD/task_list.tea"You should see four passing tests: format a task, save and restore task data, complete a task without renumbering, and reject malformed saved data. The summary reports 4 tests: 4 passed, 0 failed; timing values will vary.
Use the full test path
$PWD is your shell's current directory. The current Tea test
command resolves relative input paths against the compiler's build checkout.
Passing the full path makes this command work from your own project directory.
Now confirm that the refactored command still uses your saved tasks:
tea tasks.tea list --all1. [ ] Learn Tea
2. [x] Build a CLI
3. [x] Take a break
4. [ ] Write a testComplete checkpoint
You now have two source files. Copy both if you are resuming here; keep your existing data file. The first contains the CLI, and the second contains the task logic and its tests.
Complete tasks.tea for this chapter
tasks.tea
use args from "std.args"
use fs from "std.fs"
use task_list from "./task_list"
def help() -> Void
@println("Usage: tasks [list [--all] | add \"title\" | done NUMBER | --help]")
@println("Tasks are numbered from 1. Listing shows open tasks; --all includes completed tasks.")
end
def usage() -> Void
@eprintln("tasks: invalid arguments; run tasks --help for usage")
@exit(2)
end
def run() -> Void ! { fs.FsError, task_list.TaskError }
var argv = args.all()
if @len(argv) == 0
argv = ["list"]
end
if argv[0] == "--help" && @len(argv) == 1
help()
return
end
if argv[0] == "list"
if @len(argv) > 2 ||(@len(argv) == 2 && argv[1] != "--all")
usage()
return
end
const tasks = task_list.load_tasks("tasks.txt")
task_list.show_tasks(tasks, @len(argv) == 2)
return
end
if argv[0] == "add" && @len(argv) == 2
const tasks = task_list.load_tasks("tasks.txt")
task_list.add_task(tasks, argv[1])
task_list.save_tasks("tasks.txt", tasks)
@println(`Added task ${@len(tasks)}.`)
return
end
if argv[0] == "done" && @len(argv) == 2
const tasks = task_list.load_tasks("tasks.txt")
if task_list.complete_task(tasks, argv[1])
task_list.save_tasks("tasks.txt", tasks)
@println(`Completed task ${argv[1]}.`)
else
@println(`Task ${argv[1]} is already complete.`)
end
return
end
usage()
end
try run() catch err
case is fs.FsError.OperationFailed
@eprintln(`tasks: cannot ${err.operation} '${err.path}': ${err.message}`)
@exit(1)
case is task_list.TaskError.InvalidData
@eprintln(`tasks: invalid data on line ${err.line}; expected [ ] title or [x] title`)
@exit(1)
case is task_list.TaskError.InvalidTitle
@eprintln("tasks: title must be nonempty and on one line")
@exit(2)
case is task_list.TaskError.InvalidNumber
@eprintln(`tasks: '${err.input}' is not an existing task number; use list --all`)
@exit(2)
case _
@eprintln("tasks: unexpected error")
@exit(1)
endComplete task_list.tea for this chapter
task_list.tea
use assert from "std.assert"
use fs from "std.fs"
use parse from "std.parse"
use string from "std.string"
pub struct Task {
title: String
done: Bool
}
def format_task(task: Task, number: Int) -> String
const marker = if(task.done) "[x]" else "[ ]"
`${number}. ${marker} ${task.title}`
end
pub def show_tasks(tasks: List[Task], show_all: Bool) -> Void
if @len(tasks) == 0
@println("No tasks yet.")
return
end
var number = 1
var shown = 0
for task in tasks
if show_all ||!task.done
@println(format_task(task, number))
shown = shown + 1
end
number = number + 1
end
if shown == 0
@println("No open tasks.")
end
end
pub error TaskError {
InvalidData(line: Int)
InvalidTitle
InvalidNumber(input: String)
}
def valid_title(title: String) -> Bool
const without_spaces = string.replace(title, " ", "")
const without_tabs = string.replace(without_spaces, "\t", "")
@len(without_tabs) > 0 &&!string.contains(title, "\n") &&!string.contains(title, "\r")
end
def decode_tasks(content: String) -> List[Task] ! TaskError
var tasks: List[Task] = []
var number = 1
for line in string.lines(string.replace(content, "\r\n", "\n"))
if @len(line) < 5 ||!(string.starts_with(line, "[ ] ") || string.starts_with(line, "[x] "))
throw TaskError.InvalidData(number)
end
const title = line[4..@len(line)]
if ! valid_title(title)
throw TaskError.InvalidData(number)
end
@append(tasks, Task(title: title, done: string.starts_with(line, "[x] ")))
number = number + 1
end
tasks
end
pub def load_tasks(file: String) -> List[Task] ! { fs.FsError, TaskError }
if ! fs.exists(file)
return[]
end
decode_tasks(fs.read_file(file))
end
def encode_tasks(tasks: List[Task]) -> String
var content = ""
for task in tasks
const marker = if(task.done) "[x]" else "[ ]"
content = content + `${marker} ${task.title}\n`
end
content
end
pub def save_tasks(file: String, tasks: List[Task]) -> Void ! fs.FsError
fs.write_file_atomic(file, encode_tasks(tasks))
end
pub def add_task(tasks: List[Task], title: String) -> Void ! TaskError
if ! valid_title(title)
throw TaskError.InvalidTitle
end
@append(tasks, Task(title: title, done: false))
end
pub def complete_task(tasks: List[Task], input: String) -> Bool ! TaskError
if @len(input) == 0 || @len(input) > 19
throw TaskError.InvalidNumber(input)
end
var i = 0
while i < @len(input)
if ! string.contains("0123456789", input[i])
throw TaskError.InvalidNumber(input)
end
i = i + 1
end
const number = try parse.try_int(input) catch err
case is parse.ParseError.InvalidInt
throw TaskError.InvalidNumber(err.input)
case _
throw TaskError.InvalidNumber(input)
end
if number < 1 || number > @len(tasks) || @to_string(number) != input
throw TaskError.InvalidNumber(input)
end
const task = tasks[number - 1]
if task.done
return false
end
task.done = true
true
end
test "format a task"
const task = Task(title: "Learn Tea", done: false)
assert.eq(format_task(task, 1), "1. [ ] Learn Tea")
end
test "save and restore task data"
const tasks = [
Task(title: "Learn Tea", done: false),
Task(title: "Take a break", done: true)
]
const restored = decode_tasks(encode_tasks(tasks))
assert.eq(@len(restored), 2)
assert.eq(restored[0].title, "Learn Tea")
assert.eq(restored[1].done, true)
end
test "complete a task without renumbering"
const tasks = [Task(title: "Learn Tea", done: false)]
assert.eq(complete_task(tasks, "1"), true)
assert.eq(tasks[0].done, true)
assert.eq(complete_task(tasks, "1"), false)
assert.eq(@len(tasks), 1)
end
test "reject malformed saved data"
try decode_tasks("not a task\n") catch err
case is TaskError.InvalidData
assert.eq(err.line, 1)
return
case _
assert.ok(false)
return
end
assert.ok(false)
endTry it yourself
Add a test for formatting a completed task. Deliberately give the assertion the wrong expected marker, run the tests, then correct it.
One possible solution
test "format a completed task"
const task = Task(title: "Take a break", done: true)
assert.eq(format_task(task, 3), "3. [x] Take a break")
endWith this optional test included, the summary will report five tests. The main walkthrough has four.
You have a reusable module and an automated way to check its behavior. Next, we'll compile the tool into an executable.
Read more: modules, testing, assertions.