diff --git a/src/main/java/ted/Parser.java b/src/main/java/ted/Parser.java index 7817ce3e3d..50a858c8fe 100644 --- a/src/main/java/ted/Parser.java +++ b/src/main/java/ted/Parser.java @@ -4,6 +4,8 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -12,11 +14,14 @@ import ted.command.CommandType; import ted.command.DeleteCommand; import ted.command.ExitCommand; +import ted.command.FindByTagCommand; import ted.command.FindCommand; import ted.command.ListCommand; import ted.command.MarkCommand; +import ted.command.TagCommand; import ted.task.Deadline; import ted.task.Event; +import ted.task.Tag; import ted.task.Todo; /** @@ -24,9 +29,9 @@ *

* All of the fiddly work of reading user input lives here: splitting off the * command word, finding {@code /by}, {@code /from} and {@code /to}, and - * reading dates. Because a command is only built once its details make sense, - * the command classes themselves are free of input checking, and this class - * can be tested without a keyboard or a save file. + * reading dates and tags. Because a command is only built once its details + * make sense, the command classes themselves are free of input checking, and + * this class can be tested without a keyboard or a save file. */ public class Parser { /** @@ -72,7 +77,9 @@ public static Command parse(String input) throws TedException { case MARK -> new MarkCommand(parseTaskIndex(argument, CommandType.MARK), true); case UNMARK -> new MarkCommand(parseTaskIndex(argument, CommandType.UNMARK), false); case DELETE -> new DeleteCommand(parseTaskIndex(argument, CommandType.DELETE)); - case FIND -> new FindCommand(parseKeyword(argument)); + case FIND -> parseFind(argument); + case TAG -> parseTagCommand(argument, CommandType.TAG, true); + case UNTAG -> parseTagCommand(argument, CommandType.UNTAG, false); case TODO -> new AddCommand(parseTodo(argument)); case DEADLINE -> new AddCommand(parseDeadline(argument)); case EVENT -> new AddCommand(parseEvent(argument)); @@ -80,6 +87,24 @@ public static Command parse(String input) throws TedException { }; } + /** + * Reads a search. A keyword starting with {@code #} is a tag, which matches + * only the tasks that have exactly that tag; any other keyword is text to + * find inside descriptions. + * + * @param argument everything the user typed after the command word. + * @return the command that shows the matching tasks. + * @throws TedException if no keyword was given, or it starts with {@code #} + * but is not a valid tag. + */ + private static Command parseFind(String argument) throws TedException { + String keyword = parseKeyword(argument); + if (keyword.startsWith(Tag.PREFIX)) { + return new FindByTagCommand(parseTag(keyword, "for example: find #fun")); + } + return new FindCommand(keyword); + } + /** * Reads the keyword to search for. * @@ -167,6 +192,37 @@ private static Event parseEvent(String argument) throws TedException { return new Event(description, start, end); } + /** + * Reads a tag or untag command, from an argument of the form + * {@code # [#...]}. + * + * @param argument everything the user typed after the command word. + * @param commandType {@link CommandType#TAG} or {@link CommandType#UNTAG}, used in error messages. + * @param isAdding {@code true} to attach the tags, {@code false} to detach them. + * @return the command that changes the task's tags. + * @throws TedException if the task number or the tags are missing or unreadable. + */ + private static TagCommand parseTagCommand(String argument, CommandType commandType, boolean isAdding) + throws TedException { + String example = "for example: " + commandType.getKeyword() + " 2 #fun"; + requireNotBlank(argument, "Which task, and which tags? " + example); + + // Any run of spaces separates the words, so padding between tags does not matter. + String[] words = argument.split("\\s+"); + int index = parseTaskIndex(words[0], commandType); + if (words.length == 1) { + throw new TedException("Which tags? Start each one with #, " + example); + } + + // A loop rather than a stream, because parseTag throws a checked + // exception, which a lambda cannot pass on. + List tags = new ArrayList<>(); + for (int i = 1; i < words.length; i++) { + tags.add(parseTag(words[i], example)); + } + return new TagCommand(index, tags, isAdding); + } + /** * Converts a task number typed by the user into an index into the task list. * Whether the number points at a real task is checked by {@link ted.task.TaskList} @@ -209,6 +265,22 @@ private static LocalDateTime parseDateTime(String text, String example) throws T } } + /** + * Turns one tag typed by the user, e.g. {@code #Fun}, into a {@link Tag}. + * + * @param text one word as typed. + * @param example wording showing how the command is used, used in the error message. + * @return the tag the word stands for. + * @throws TedException if the word is not {@code #} followed by letters and digits. + */ + private static Tag parseTag(String text, String example) throws TedException { + if (!Tag.isValidText(text)) { + throw new TedException("\"" + text + "\" is not a tag. " + + "A tag is # followed by letters and digits, " + example); + } + return Tag.fromText(text); + } + /** * Returns where an option such as {@code /to} starts in the argument. * Only a standalone word counts, so that the {@code /to} inside a diff --git a/src/main/java/ted/Storage.java b/src/main/java/ted/Storage.java index adf6606cf8..0ebf58c04e 100644 --- a/src/main/java/ted/Storage.java +++ b/src/main/java/ted/Storage.java @@ -6,11 +6,13 @@ import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.regex.Pattern; import ted.task.Deadline; import ted.task.Event; +import ted.task.Tag; import ted.task.Task; import ted.task.TaskList; import ted.task.Todo; @@ -23,12 +25,14 @@ * tests point Ted at a scratch file instead of the real one. A relative path * such as {@code data/ted.txt} is built with {@link Path}, so it works the same * on any computer and any operating system. Each task is one line, formatted - * by {@link Task#toSaveFormat()}: + * by {@link Task#toSaveFormat()}. A tagged task carries its tags as an extra + * field straight after the done flag: * *

  * T | 0 | borrow book
+ * T | 1 | #fun #school | read book
  * D | 0 | 2019-06-06T18:00 | return book
- * E | 0 | 2019-08-06T14:00 | 2019-08-06T16:00 | project meeting
+ * E | 0 | #cs2103 | 2019-08-06T14:00 | 2019-08-06T16:00 | project meeting
  * 
*/ public class Storage { @@ -130,7 +134,8 @@ public int getSkippedLineCount() { /** * Rebuilds a single task from one line of the data file. *

- * The line has the shape {@code | | | }. + * The line has the shape + * {@code | | [ |] | }. * Pipes and backslashes inside fields are escaped. The limited split also * preserves descriptions containing raw separators from save files created * before escaping was introduced. @@ -141,14 +146,24 @@ public int getSkippedLineCount() { private static Task parseLine(String line) { assert !line.isBlank() : "load() skips blank lines, so they are never parsed"; - // A first, unlimited split just to read the type icon safely: even a - // corrupted line must have at least its icon before anything can be parsed. - String icon = line.split(FIELD_SEPARATOR_REGEX)[0]; + // A first, unlimited split to read the type icon and look for a tags + // field: even a corrupted line must have at least its icon before + // anything can be parsed. + String[] rawFields = line.split(FIELD_SEPARATOR_REGEX); + String icon = rawFields[0]; int fieldCount = fieldCountFor(icon); if (fieldCount == -1) { return null; } + // A tagged task has one extra field, straight after the done flag. The + // field must also hold valid tags, so that an older line whose + // description contains a raw separator is still read as it always was. + boolean hasTags = rawFields.length > fieldCount && isTagsField(rawFields[2]); + if (hasTags) { + fieldCount++; + } + // A limited split keeps legacy raw separators inside the final description. String[] fields = line.split(FIELD_SEPARATOR_REGEX, fieldCount); if (fields.length != fieldCount) { @@ -160,19 +175,22 @@ private static Task parseLine(String line) { String description = decodeSaveField(fields[fieldCount - 1]); boolean isDone = fields[1].equals("1"); + List tags = hasTags ? parseSavedTags(fields[2]) : List.of(); + // The tags field, when present, pushes the date fields one place along. + int firstDateField = hasTags ? 3 : 2; try { switch (icon) { case "T": - return withDone(new Todo(description), isDone); + return withSavedState(new Todo(description), isDone, tags); case "D": - return withDone(new Deadline( - description, parseSavedDateTime(fields[2])), isDone); + return withSavedState(new Deadline( + description, parseSavedDateTime(fields[firstDateField])), isDone, tags); case "E": - return withDone(new Event( + return withSavedState(new Event( description, - parseSavedDateTime(fields[2]), - parseSavedDateTime(fields[3])), - isDone); + parseSavedDateTime(fields[firstDateField]), + parseSavedDateTime(fields[firstDateField + 1])), + isDone, tags); default: assert false : "unreachable, as fieldCountFor accepts only T, D and E"; return null; @@ -198,8 +216,34 @@ private static LocalDateTime parseSavedDateTime(String field) { } /** - * Number of fields a valid save line has for a given task type: + * Returns whether a saved field holds tags: one or more tags separated by + * single spaces, e.g. {@code #fun #school}. + * + * @param field one field from the save file. + * @return {@code true} if every word in the field is a valid tag. + */ + private static boolean isTagsField(String field) { + // The -1 keeps empty strings from stray spaces, so they fail the check + // instead of being silently dropped. + return Arrays.stream(field.split(" ", -1)).allMatch(Tag::isValidText); + } + + /** + * Reads back the tags written by {@link Task#toSaveFormat()}. + * + * @param field a field that {@link #isTagsField(String)} accepts. + * @return the tags, in the order they were saved. + */ + private static List parseSavedTags(String field) { + return Arrays.stream(field.split(" ")) + .map(Tag::fromText) + .toList(); + } + + /** + * Number of fields a valid untagged save line has for a given task type: * the icon and the done flag, plus the date fields, plus the description. + * A tagged line has one more, for its tags. * * @return 3 for a todo, 4 for a deadline, 5 for an event, or -1 if the icon is unknown. */ @@ -239,13 +283,14 @@ private static String decodeSaveField(String field) { } /** - * Applies the saved done flag to a freshly created task, which always - * starts out undone. + * Applies the saved done flag and tags to a freshly created task, which + * always starts out undone and untagged. */ - private static Task withDone(Task task, boolean isDone) { + private static Task withSavedState(Task task, boolean isDone, List tags) { if (isDone) { task.markAsDone(); } + task.addTags(tags); return task; } } diff --git a/src/main/java/ted/Ui.java b/src/main/java/ted/Ui.java index e2551bfe82..ce00e9be53 100644 --- a/src/main/java/ted/Ui.java +++ b/src/main/java/ted/Ui.java @@ -163,6 +163,17 @@ public void showMarked(Task task, boolean isDone) { " " + task); } + /** + * Confirms a change to a task's tags. + * + * @param task the task whose tags changed. + * @param isAdding {@code true} if tags were attached, {@code false} if detached. + */ + public void showTagged(Task task, boolean isAdding) { + show(isAdding ? "OK, I've tagged this task:" : "OK, I've untagged this task:", + " " + task); + } + /** * Shows every stored task as a numbered list, starting from 1. * diff --git a/src/main/java/ted/command/CommandType.java b/src/main/java/ted/command/CommandType.java index 68450ab64b..2c633978d9 100644 --- a/src/main/java/ted/command/CommandType.java +++ b/src/main/java/ted/command/CommandType.java @@ -37,9 +37,15 @@ public enum CommandType { /** Removes a task from the list. */ DELETE("delete"), - /** Shows only the tasks whose description contains a keyword. */ + /** Shows only the tasks whose description contains a keyword, or that have a tag. */ FIND("find"), + /** Attaches tags to a task. */ + TAG("tag"), + + /** Detaches tags from a task. */ + UNTAG("untag"), + /** Ends the conversation. */ BYE("bye"); diff --git a/src/main/java/ted/command/FindByTagCommand.java b/src/main/java/ted/command/FindByTagCommand.java new file mode 100644 index 0000000000..6b6975d002 --- /dev/null +++ b/src/main/java/ted/command/FindByTagCommand.java @@ -0,0 +1,33 @@ +package ted.command; + +import ted.Storage; +import ted.Ui; +import ted.task.Tag; +import ted.task.TaskList; + +/** + * Shows only the tasks that have a given tag. + *

+ * Kept apart from {@link FindCommand} because matching a whole tag is a + * different rule from finding text inside a description. Like a keyword + * search, it only looks at the list, so nothing is saved. + */ +public class FindByTagCommand extends Command { + /** The tag a task must have to be shown. */ + private final Tag tag; + + /** + * Creates a command that shows the tasks with the given tag. + * + * @param tag the tag to look for. + */ + public FindByTagCommand(Tag tag) { + this.tag = tag; + } + + /** Shows the tasks with the tag. Nothing is changed, so nothing is saved. */ + @Override + public void execute(TaskList tasks, Ui ui, Storage storage) { + ui.showMatchingTasks(tasks.findByTag(tag), tag.toString()); + } +} diff --git a/src/main/java/ted/command/TagCommand.java b/src/main/java/ted/command/TagCommand.java new file mode 100644 index 0000000000..4c9448ea5c --- /dev/null +++ b/src/main/java/ted/command/TagCommand.java @@ -0,0 +1,77 @@ +package ted.command; + +import java.util.List; + +import ted.Storage; +import ted.TedException; +import ted.Ui; +import ted.task.Tag; +import ted.task.Task; +import ted.task.TaskList; + +/** + * Attaches tags to a task, or detaches them. + * Tagging and untagging differ only in which way the task's tags change and in + * the wording, so one class covers both, the way {@link MarkCommand} covers + * mark and unmark. + */ +public class TagCommand extends Command { + /** Zero-based position of the task to change. */ + private final int index; + + /** The tags to attach or detach. */ + private final List tags; + + /** {@code true} to attach the tags, {@code false} to detach them. */ + private final boolean isAdding; + + /** + * Creates a command that changes one task's tags. + * + * @param index zero-based position of the task. + * @param tags the tags to attach or detach. + * @param isAdding {@code true} to attach them, {@code false} to detach them. + */ + public TagCommand(int index, List tags, boolean isAdding) { + this.index = index; + this.tags = List.copyOf(tags); + this.isAdding = isAdding; + } + + /** + * Changes the task's tags, shows the result, and saves the list. + * + * @throws TedException if no task has that number, an untag names a tag the + * task does not have, or the list cannot be saved. + */ + @Override + public void execute(TaskList tasks, Ui ui, Storage storage) throws TedException { + Task task = tasks.get(index); + if (isAdding) { + task.addTags(tags); + } else { + requireAllPresent(task); + task.removeTags(tags); + } + + ui.showTagged(task, isAdding); + storage.save(tasks); + } + + /** + * Rejects an untag that names a tag the task does not have. Every tag is + * checked before any is removed, so a typo in one of them cannot leave the + * task half changed. + * + * @param task the task about to lose some of its tags. + * @throws TedException if the task is missing one of the tags. + */ + private void requireAllPresent(Task task) throws TedException { + for (Tag tag : tags) { + if (!task.hasTag(tag)) { + throw new TedException("Task " + (index + 1) + " doesn't have the tag " + tag + + ", so nothing was changed."); + } + } + } +} diff --git a/src/main/java/ted/task/Tag.java b/src/main/java/ted/task/Tag.java new file mode 100644 index 0000000000..0629daeb45 --- /dev/null +++ b/src/main/java/ted/task/Tag.java @@ -0,0 +1,73 @@ +package ted.task; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * A label the user attaches to a task, e.g. {@code #fun}, so that related + * tasks can be picked out of the list together. + *

+ * A tag is nothing more than its name, so it is written as a record: Java + * generates {@code equals} and {@code hashCode} from the name, which is what + * lets a task keep its tags in a {@link java.util.Set} without repeats. The + * name is stored in lower case, so {@code #Fun} and {@code #fun} are the same + * tag. + * + * @param name the tag's name without the leading {@code #}, in lower case. + */ +public record Tag(String name) { + /** Marks a word as a tag, both when the user types it and when Ted shows it. */ + public static final String PREFIX = "#"; + + /** + * What a tag name may contain. Letters and digits only, so that a tag can + * never contain the spaces or {@code |} characters the save file relies on. + */ + private static final Pattern VALID_NAME = Pattern.compile("[A-Za-z0-9]+"); + + /** + * Creates a tag with the given name, converted to lower case. + * + * @throws IllegalArgumentException if the name is empty or contains + * anything other than letters and digits. + */ + public Tag { + if (!VALID_NAME.matcher(name).matches()) { + throw new IllegalArgumentException("A tag name must be letters and digits only: " + name); + } + name = name.toLowerCase(Locale.ROOT); + } + + /** + * Returns whether the text is a tag as the user writes it: {@code #} + * followed by letters and digits, e.g. {@code #fun} or {@code #CS2103}. + * + * @param text a single word, e.g. from the user's input or the save file. + * @return {@code true} if {@link #fromText(String)} accepts the text. + */ + public static boolean isValidText(String text) { + return text.startsWith(PREFIX) && VALID_NAME.matcher(text.substring(PREFIX.length())).matches(); + } + + /** + * Creates the tag written as the given text, e.g. {@code #Fun} gives {@code #fun}. + * + * @param text a tag as the user writes it, starting with {@code #}. + * @return the tag the text stands for. + * @throws IllegalArgumentException if {@link #isValidText(String)} rejects the text. + */ + public static Tag fromText(String text) { + if (!isValidText(text)) { + throw new IllegalArgumentException("Not a tag: " + text); + } + return new Tag(text.substring(PREFIX.length())); + } + + /** + * Returns the tag as the user sees it, e.g. {@code #fun}. + */ + @Override + public String toString() { + return PREFIX + name; + } +} diff --git a/src/main/java/ted/task/Task.java b/src/main/java/ted/task/Task.java index ef39529e2e..dcb4368710 100644 --- a/src/main/java/ted/task/Task.java +++ b/src/main/java/ted/task/Task.java @@ -1,6 +1,10 @@ package ted.task; import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; /** * A single task that Ted keeps track of. @@ -26,6 +30,12 @@ public abstract class Task { /** Whether the task has been completed. */ private boolean isDone; + /** + * Labels the user has attached to this task, in the order they were added. + * A set, so that attaching a tag the task already has changes nothing. + */ + private final Set tags = new LinkedHashSet<>(); + /** * Creates a task that is not done yet. * @@ -65,12 +75,41 @@ public void markAsNotDone() { this.isDone = false; } + /** + * Attaches tags to this task. Tags it already has are left where they are, + * so no tag is ever shown twice. + * + * @param newTags the tags to attach. + */ + public void addTags(Collection newTags) { + tags.addAll(newTags); + } + + /** + * Detaches tags from this task. Tags it does not have are ignored. + * + * @param oldTags the tags to detach. + */ + public void removeTags(Collection oldTags) { + tags.removeAll(oldTags); + } + + /** + * Returns whether this task has the given tag. + * + * @param tag the tag to look for. + * @return {@code true} if the tag is attached to this task. + */ + public boolean hasTag(Tag tag) { + return tags.contains(tag); + } + /** * Converts this task into a single line of the save file format. *

* The line carries everything needed to rebuild this task: the type icon - * ({@link #getTypeIcon()}), the done flag, the description, and any - * type-specific detail. Todo uses the default implementation; subclasses + * ({@link #getTypeIcon()}), the done flag, any tags, the description, and + * any type-specific detail. Todo uses the default implementation; subclasses * override it to pass their extra fields to {@link #toSaveLine(String...)}. * * @return one line of the save file, using {@code " | "} as the separator. @@ -87,6 +126,10 @@ public String toSaveFormat() { * deadline one, an event two -- so they are taken as varargs. Each caller * then names its own fields in order and this method alone deals with * escaping them and placing the separators. + *

+ * Tags, if there are any, form one field straight after the done flag, e.g. + * {@code T | 0 | #fun #school | read book}. A task without tags leaves the + * field out, so its line is exactly what Ted wrote before tags existed. * * @param extraFields type-specific fields, in the order they are saved, * stored between the done flag and the description. @@ -96,6 +139,10 @@ protected String toSaveLine(String... extraFields) { StringBuilder line = new StringBuilder(); line.append(getTypeIcon()).append(SAVE_FIELD_SEPARATOR) .append(isDone ? "1" : "0").append(SAVE_FIELD_SEPARATOR); + if (!tags.isEmpty()) { + // Tags need no escaping: after the # they are only letters and digits. + line.append(formatTags()).append(SAVE_FIELD_SEPARATOR); + } for (String field : extraFields) { line.append(encodeSaveField(field)).append(SAVE_FIELD_SEPARATOR); } @@ -123,13 +170,24 @@ protected static String encodeSaveField(String field) { public abstract String getTypeIcon(); /** - * Returns this task as it should appear to the user, e.g. {@code [T][X] read book}. + * Returns this task as it should appear to the user, e.g. {@code [T][X] read book #fun}. * Overriding {@code toString} rather than writing a separate format method * lets a task be printed directly wherever it is needed. Subclasses that - * carry extra detail append it to this result. + * carry extra detail append it to this result, so a deadline's due date + * comes after its tags. */ @Override public String toString() { - return "[" + getTypeIcon() + "][" + getStatusIcon() + "] " + description; + String shown = "[" + getTypeIcon() + "][" + getStatusIcon() + "] " + description; + return tags.isEmpty() ? shown : shown + " " + formatTags(); + } + + /** + * Returns the tags the way they are both shown and saved, e.g. {@code #fun #school}. + */ + private String formatTags() { + return tags.stream() + .map(Tag::toString) + .collect(Collectors.joining(" ")); } } diff --git a/src/main/java/ted/task/TaskList.java b/src/main/java/ted/task/TaskList.java index dd3c4dae67..37cc8cdadb 100644 --- a/src/main/java/ted/task/TaskList.java +++ b/src/main/java/ted/task/TaskList.java @@ -99,6 +99,21 @@ public TaskList find(String keyword) { .toList()); } + /** + * Returns the tasks that have the given tag. + *

+ * Only the exact tag counts, so {@code #fun} does not match a task tagged + * {@code #funny}: a tag is a label the user chose, not text to search inside. + * + * @param tag the tag to look for. + * @return the tasks with that tag, in the order they appear in this list. + */ + public TaskList findByTag(Tag tag) { + return new TaskList(tasks.stream() + .filter(task -> task.hasTag(tag)) + .toList()); + } + /** * Returns the tasks as a plain list, for code that only needs to read them. * diff --git a/src/test/java/ted/ParserTest.java b/src/test/java/ted/ParserTest.java index 767a957703..c6d4ae4258 100644 --- a/src/test/java/ted/ParserTest.java +++ b/src/test/java/ted/ParserTest.java @@ -17,6 +17,7 @@ import ted.command.FindCommand; import ted.command.ListCommand; import ted.command.MarkCommand; +import ted.command.TagCommand; import ted.task.Task; import ted.task.TaskList; import ted.task.Todo; @@ -218,6 +219,96 @@ public void parse_deleteWithNumber_indexIsZeroBased() throws TedException { assertEquals(0, tasks.size()); } + @Test + public void parse_tagOrUntagWithTags_returnsTagCommand() throws TedException { + assertInstanceOf(TagCommand.class, Parser.parse("tag 1 #fun")); + assertInstanceOf(TagCommand.class, Parser.parse("untag 1 #fun #school")); + } + + @Test + public void parse_tagThenExecute_tagsTheTaskInLowerCase() throws TedException { + TaskList tasks = new TaskList(List.of(new Todo("read book"))); + // Extra spaces between the tags, and a repeated tag, make no difference. + Parser.parse("tag 1 #Fun #school #FUN").execute(tasks, new SilentUi(), new NoOpStorage()); + assertEquals("[T][ ] read book #fun #school", tasks.get(0).toString()); + } + + @Test + public void parse_untagThenExecute_removesOnlyThoseTags() throws TedException { + TaskList tasks = new TaskList(List.of(new Todo("read book"))); + Parser.parse("tag 1 #fun #school").execute(tasks, new SilentUi(), new NoOpStorage()); + Parser.parse("untag 1 #school").execute(tasks, new SilentUi(), new NoOpStorage()); + assertEquals("[T][ ] read book #fun", tasks.get(0).toString()); + } + + @Test + public void parse_tagWithoutTaskOrTags_exceptionThrown() { + TedException noArgument = assertThrows(TedException.class, () -> Parser.parse("tag")); + assertTrue(noArgument.getMessage().contains("tag 2 #fun")); + TedException noTags = assertThrows(TedException.class, () -> Parser.parse("untag 2")); + assertTrue(noTags.getMessage().contains("untag 2 #fun")); + } + + @Test + public void parse_tagWithBadTaskNumber_exceptionThrown() { + TedException e = assertThrows(TedException.class, () -> Parser.parse("tag two #fun")); + assertTrue(e.getMessage().contains("\"two\" is not a task number")); + } + + @Test + public void parse_tagWithInvalidTag_exceptionThrown() { + TedException e = assertThrows(TedException.class, () -> Parser.parse("tag 2 fun")); + assertTrue(e.getMessage().contains("\"fun\" is not a tag")); + assertThrows(TedException.class, () -> Parser.parse("tag 2 #")); + assertThrows(TedException.class, () -> Parser.parse("tag 2 #to-do")); + } + + @Test + public void parse_untagTagTheTaskLacks_exceptionThrownAndTaskUnchanged() throws TedException { + TaskList tasks = new TaskList(List.of(new Todo("read book"))); + Parser.parse("tag 1 #fun").execute(tasks, new SilentUi(), new NoOpStorage()); + + Command untag = Parser.parse("untag 1 #fun #nope"); + Storage storage = new NoOpStorage(); + TedException e = assertThrows(TedException.class, () -> untag.execute(tasks, new SilentUi(), storage)); + assertTrue(e.getMessage().contains("doesn't have the tag #nope")); + // All or nothing: #fun must survive the rejected untag. + assertEquals("[T][ ] read book #fun", tasks.get(0).toString()); + } + + @Test + public void parse_tagTaskNumberPastEnd_exceptionThrown() throws TedException { + TaskList tasks = new TaskList(List.of(new Todo("read book"))); + Command tag = Parser.parse("tag 9 #fun"); + assertThrows(TedException.class, () -> tag.execute(tasks, new SilentUi(), new NoOpStorage())); + } + + @Test + public void parse_findWithTag_showsOnlyTasksWithThatTag() throws TedException { + TaskList tasks = new TaskList(List.of(new Todo("read book"), new Todo("fun fair"))); + Parser.parse("tag 1 #fun").execute(tasks, new SilentUi(), new NoOpStorage()); + + Ui ui = new SilentUi(); + Parser.parse("find #FUN").execute(tasks, ui, new NoOpStorage()); + String reply = ui.flush(); + assertTrue(reply.contains("read book #fun")); + // "fun fair" contains the text "fun" but has no #fun tag. + assertFalse(reply.contains("fun fair")); + } + + @Test + public void parse_findWithInvalidTag_exceptionThrown() { + TedException e = assertThrows(TedException.class, () -> Parser.parse("find #fun #school")); + assertTrue(e.getMessage().contains("find #fun")); + assertThrows(TedException.class, () -> Parser.parse("find #")); + } + + @Test + public void parse_findWithHashLaterInKeyword_returnsFindCommand() throws TedException { + // Only a keyword that starts with # is a tag search. + assertInstanceOf(FindCommand.class, Parser.parse("find book #fun")); + } + /** A Ui that says nothing, so tests do not print over the test report. */ private static class SilentUi extends Ui { @Override diff --git a/src/test/java/ted/StorageTest.java b/src/test/java/ted/StorageTest.java index 1a79a6dffe..5a739a8234 100644 --- a/src/test/java/ted/StorageTest.java +++ b/src/test/java/ted/StorageTest.java @@ -15,6 +15,7 @@ import ted.task.Deadline; import ted.task.Event; +import ted.task.Tag; import ted.task.Task; import ted.task.TaskList; import ted.task.Todo; @@ -151,4 +152,73 @@ public void load_fileHandEditedByUser_isAccepted(@TempDir Path tempDir) assertEquals(1, loaded.size()); assertEquals("D | 1 | 2019-12-02T18:00 | return book", loaded.get(0).toSaveFormat()); } + + @Test + public void saveThenLoad_taggedTasks_roundTripsUnchanged(@TempDir Path tempDir) throws TedException { + Storage storage = new Storage(tempDir.resolve("ted.txt").toString()); + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + todo.markAsDone(); + Deadline deadline = new Deadline("return book", SECOND_OF_DECEMBER_6PM); + deadline.addTags(List.of(new Tag("library"))); + Event event = new Event("meeting", SECOND_OF_DECEMBER_4PM, SECOND_OF_DECEMBER_6PM); + event.addTags(List.of(new Tag("cs2103"))); + TaskList saved = new TaskList(List.of(todo, deadline, event)); + + storage.save(saved); + List loaded = storage.load(); + + assertEquals(3, loaded.size()); + assertEquals(0, storage.getSkippedLineCount()); + assertInstanceOf(Deadline.class, loaded.get(1)); + assertInstanceOf(Event.class, loaded.get(2)); + // The save format includes the tags, so matching it proves they came back. + for (int i = 0; i < loaded.size(); i++) { + assertEquals(saved.asList().get(i).toSaveFormat(), loaded.get(i).toSaveFormat()); + } + } + + @Test + public void load_linesWrittenBeforeTags_readAsBefore(@TempDir Path tempDir) + throws TedException, IOException { + // None of these lines has a tags field, so each must load exactly as it + // did before tags existed, even where the description looks like a tag. + Path dataFile = tempDir.resolve("ted.txt"); + Files.write(dataFile, List.of( + "T | 0 | #fun", + "T | 0 | rock | roll", + "D | 1 | 2019-12-02T18:00 | return book")); + + Storage storage = new Storage(dataFile.toString()); + List loaded = storage.load(); + + assertEquals(3, loaded.size()); + assertEquals(0, storage.getSkippedLineCount()); + assertEquals("T | 0 | #fun", loaded.get(0).toSaveFormat()); + assertEquals("[T][ ] rock | roll", loaded.get(1).toString()); + assertEquals("D | 1 | 2019-12-02T18:00 | return book", loaded.get(2).toSaveFormat()); + } + + @Test + public void load_tagsInCapitalsOrRepeated_loadedOnceInLowerCase(@TempDir Path tempDir) + throws TedException, IOException { + // A hand-edited file need not match what Ted writes itself. + Path dataFile = tempDir.resolve("ted.txt"); + Files.writeString(dataFile, "T | 0 | #Fun #fun #School | read book\n"); + + List loaded = new Storage(dataFile.toString()).load(); + assertEquals("T | 0 | #fun #school | read book", loaded.get(0).toSaveFormat()); + } + + @Test + public void load_fieldThatIsNotValidTags_keptInDescription(@TempDir Path tempDir) + throws TedException, IOException { + // "#to-do" is not a tag, so the line is read like an older line whose + // description holds an unescaped separator, rather than being skipped. + Path dataFile = tempDir.resolve("ted.txt"); + Files.writeString(dataFile, "T | 0 | #to-do | read book\n"); + + List loaded = new Storage(dataFile.toString()).load(); + assertEquals("[T][ ] #to-do | read book", loaded.get(0).toString()); + } } diff --git a/src/test/java/ted/task/TagTest.java b/src/test/java/ted/task/TagTest.java new file mode 100644 index 0000000000..b98ed019eb --- /dev/null +++ b/src/test/java/ted/task/TagTest.java @@ -0,0 +1,63 @@ +package ted.task; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Tag}, which holds the one rule for what a tag may be. + *

+ * The parser, the save file and the tasks all rely on this rule, so a mistake + * here would show up in all three. + */ +public class TagTest { + @Test + public void constructor_mixedCaseName_storedInLowerCase() { + assertEquals("fun", new Tag("Fun").name()); + assertEquals("#cs2103", new Tag("CS2103").toString()); + } + + @Test + public void equals_sameNameInDifferentCase_sameTag() { + // #Fun and #fun must be one tag, or a task could end up showing both. + assertEquals(new Tag("fun"), new Tag("FUN")); + } + + @Test + public void constructor_nameWithOtherCharacters_exceptionThrown() { + assertThrows(IllegalArgumentException.class, () -> new Tag("")); + assertThrows(IllegalArgumentException.class, () -> new Tag("to-do")); + assertThrows(IllegalArgumentException.class, () -> new Tag("fun school")); + // A pipe inside a tag would break the save file's separators. + assertThrows(IllegalArgumentException.class, () -> new Tag("a|b")); + } + + @Test + public void isValidText_hashThenLettersAndDigits_accepted() { + assertTrue(Tag.isValidText("#fun")); + assertTrue(Tag.isValidText("#CS2103")); + } + + @Test + public void isValidText_otherText_rejected() { + assertFalse(Tag.isValidText("fun")); + assertFalse(Tag.isValidText("#")); + assertFalse(Tag.isValidText("##fun")); + assertFalse(Tag.isValidText("#to-do")); + assertFalse(Tag.isValidText("#fun #school")); + assertFalse(Tag.isValidText("")); + } + + @Test + public void fromText_validText_returnsTagWithoutHash() { + assertEquals(new Tag("fun"), Tag.fromText("#Fun")); + } + + @Test + public void fromText_invalidText_exceptionThrown() { + assertThrows(IllegalArgumentException.class, () -> Tag.fromText("fun")); + } +} diff --git a/src/test/java/ted/task/TaskListTest.java b/src/test/java/ted/task/TaskListTest.java index daefa389b6..2566785f71 100644 --- a/src/test/java/ted/task/TaskListTest.java +++ b/src/test/java/ted/task/TaskListTest.java @@ -151,4 +151,26 @@ public void asList_returnedList_cannotBeModified() { List view = tasks.asList(); assertThrows(UnsupportedOperationException.class, () -> view.add(new Todo("sneaky"))); } + + @Test + public void findByTag_similarTags_returnsOnlyExactMatches() throws TedException { + Todo fun = new Todo("read book"); + fun.addTags(List.of(new Tag("fun"))); + Todo funny = new Todo("watch film"); + funny.addTags(List.of(new Tag("funny"))); + TaskList tasks = new TaskList(List.of(fun, funny, new Todo("buy milk"))); + + TaskList matches = tasks.findByTag(new Tag("fun")); + // #funny starts with "fun" but is a different tag, so it must not match. + assertEquals(1, matches.size()); + assertSame(fun, matches.get(0)); + } + + @Test + public void find_keywordOnlyInATag_noMatch() { + // find searches descriptions, and a tag is not part of the description. + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"))); + assertTrue(new TaskList(List.of(todo)).find("fun").isEmpty()); + } } diff --git a/src/test/java/ted/task/TaskTest.java b/src/test/java/ted/task/TaskTest.java index f7257ceb73..05f4b7b898 100644 --- a/src/test/java/ted/task/TaskTest.java +++ b/src/test/java/ted/task/TaskTest.java @@ -1,9 +1,11 @@ package ted.task; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.Test; @@ -96,4 +98,60 @@ public void toSaveFormat_descriptionContainingSeparator_escapesIt() { public void toSaveFormat_descriptionContainingBackslash_escapesIt() { assertEquals("T | 0 | back\\\\slash", new Todo("back\\slash").toSaveFormat()); } + + @Test + public void toString_taggedTodo_showsTagsAfterDescription() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + assertEquals("[T][ ] read book #fun #school", todo.toString()); + } + + @Test + public void toString_taggedDeadline_showsTagsBeforeDate() { + Deadline deadline = new Deadline("return book", SECOND_OF_DECEMBER_6PM); + deadline.addTags(List.of(new Tag("library"))); + assertTrue(deadline.toString().startsWith("[D][ ] return book #library (by: ")); + } + + @Test + public void addTags_tagAlreadyPresent_notRepeated() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"))); + todo.addTags(List.of(new Tag("FUN"), new Tag("school"))); + // The repeated tag keeps its original place rather than moving to the end. + assertEquals("[T][ ] read book #fun #school", todo.toString()); + } + + @Test + public void removeTags_someOfTheTags_removesOnlyThose() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + todo.removeTags(List.of(new Tag("school"))); + assertTrue(todo.hasTag(new Tag("fun"))); + assertFalse(todo.hasTag(new Tag("school"))); + } + + @Test + public void toSaveFormat_taggedTodo_writesTagsAfterDoneFlag() { + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"), new Tag("school"))); + assertEquals("T | 0 | #fun #school | read book", todo.toSaveFormat()); + } + + @Test + public void toSaveFormat_taggedEvent_writesTagsBeforeDates() { + Event event = new Event("meeting", SECOND_OF_DECEMBER_4PM, SECOND_OF_DECEMBER_6PM); + event.addTags(List.of(new Tag("cs2103"))); + assertEquals("E | 0 | #cs2103 | 2019-12-02T16:00 | 2019-12-02T18:00 | meeting", + event.toSaveFormat()); + } + + @Test + public void toSaveFormat_lastTagRemoved_writesUntaggedLine() { + // Without tags the line must be exactly what Ted wrote before tags existed. + Todo todo = new Todo("read book"); + todo.addTags(List.of(new Tag("fun"))); + todo.removeTags(List.of(new Tag("fun"))); + assertEquals("T | 0 | read book", todo.toSaveFormat()); + } }