From 38e6fee5169d1a26c5d65b937ad48a1b63666709 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Tue, 24 Oct 2023 22:27:09 +0800 Subject: [PATCH 01/11] Modify Find Command to accept financial plans and tags Currently, the find command is rather limited in scope. It is only able to search for names while a user might want to search by financial plans or tags. Let's: - Expand the find command to search by tags and financial plans (substrings included) We choose to search names by whole words because it is unlikely to search for names by substrings, while tags and financial plans may have substrings that make sense to search for. Testing will be added in a future commit. --- .../address/logic/commands/FindCommand.java | 26 +++++-- .../logic/parser/FindCommandParser.java | 31 +++++++- .../logic/parser/GatherCommandParser.java | 17 +---- .../address/logic/parser/ParserUtil.java | 74 ++++++++++++++++++- ...inancialPlanContainsKeywordsPredicate.java | 49 ++++++++++++ .../NameContainsKeywordsPredicate.java | 3 +- .../TagContainsKeywordsPredicate.java | 48 ++++++++++++ .../java/seedu/address/model/tag/Tag.java | 1 - .../java/seedu/address/ui/MainWindow.java | 2 - .../logic/commands/CommandTestUtil.java | 2 +- .../logic/commands/FindCommandTest.java | 2 +- .../logic/parser/AddressBookParserTest.java | 2 +- .../logic/parser/FindCommandParserTest.java | 2 +- .../seedu/address/model/ModelManagerTest.java | 2 +- .../NameContainsKeywordsPredicateTest.java | 1 + 15 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java rename src/main/java/seedu/address/model/person/{ => predicates}/NameContainsKeywordsPredicate.java (93%) create mode 100644 src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java diff --git a/src/main/java/seedu/address/logic/commands/FindCommand.java b/src/main/java/seedu/address/logic/commands/FindCommand.java index 72b9eddd3a7..7bff17114c7 100644 --- a/src/main/java/seedu/address/logic/commands/FindCommand.java +++ b/src/main/java/seedu/address/logic/commands/FindCommand.java @@ -1,28 +1,38 @@ package seedu.address.logic.commands; import static java.util.Objects.requireNonNull; +import static seedu.address.logic.parser.CliSyntax.PREFIX_FINANCIAL_PLAN; +import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME; +import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG; + +import java.util.function.Predicate; import seedu.address.commons.util.ToStringBuilder; import seedu.address.logic.Messages; import seedu.address.model.Model; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.Person; /** - * Finds and lists all persons in address book whose name contains any of the argument keywords. - * Keyword matching is case insensitive. + * Finds and lists all persons in address book whose name, tags or financial plans contains any of the argument + * keywords. Keyword matching is case-insensitive. */ public class FindCommand extends Command { public static final String COMMAND_WORD = "find"; - public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose names contain any of " + public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose names, tags or financial" + + " plans contain any of " + "the specified keywords (case-insensitive) and displays them as a list with index numbers.\n" - + "Parameters: KEYWORD [MORE_KEYWORDS]...\n" - + "Example: " + COMMAND_WORD + " alice bob charlie"; + + "Parameters: " + + "[" + PREFIX_NAME + "NAME] and/or " + + "[" + PREFIX_FINANCIAL_PLAN + "FINANCIAL_PLAN] and/or " + + "[" + PREFIX_TAG + "TAG]\n" + + "Example: " + COMMAND_WORD + " " + PREFIX_FINANCIAL_PLAN + "Financial Plan A" + + " " + PREFIX_TAG + "TagA"; - private final NameContainsKeywordsPredicate predicate; + private final Predicate predicate; - public FindCommand(NameContainsKeywordsPredicate predicate) { + public FindCommand(Predicate predicate) { this.predicate = predicate; } diff --git a/src/main/java/seedu/address/logic/parser/FindCommandParser.java b/src/main/java/seedu/address/logic/parser/FindCommandParser.java index 2867bde857b..a81a39ccf99 100644 --- a/src/main/java/seedu/address/logic/parser/FindCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/FindCommandParser.java @@ -1,12 +1,22 @@ package seedu.address.logic.parser; import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT; +import static seedu.address.logic.parser.CliSyntax.PREFIX_FINANCIAL_PLAN; +import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME; +import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG; +import static seedu.address.logic.parser.ParserUtil.validateFinancialPlans; +import static seedu.address.logic.parser.ParserUtil.validateNames; +import static seedu.address.logic.parser.ParserUtil.validateTags; -import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; import seedu.address.logic.commands.FindCommand; import seedu.address.logic.parser.exceptions.ParseException; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.Person; +import seedu.address.model.person.predicates.FinancialPlanContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.TagContainsKeywordsPredicate; /** * Parses input arguments and creates a new FindCommand object @@ -24,10 +34,23 @@ public FindCommand parse(String args) throws ParseException { throw new ParseException( String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); } + ArgumentMultimap argMultimap = ArgumentTokenizer.tokenize(args, PREFIX_NAME, + PREFIX_FINANCIAL_PLAN, PREFIX_TAG); - String[] nameKeywords = trimmedArgs.split("\\s+"); + List nameKeywords = argMultimap.getAllValues(PREFIX_NAME); + validateNames(nameKeywords); - return new FindCommand(new NameContainsKeywordsPredicate(Arrays.asList(nameKeywords))); + List tagKeywords = argMultimap.getAllValues(PREFIX_TAG); + validateTags(tagKeywords); + + List financialPlanKeywords = argMultimap.getAllValues(PREFIX_FINANCIAL_PLAN); + validateFinancialPlans(financialPlanKeywords); + + Predicate combinedPredicate = new NameContainsKeywordsPredicate(nameKeywords) + .or(new TagContainsKeywordsPredicate(tagKeywords)) + .or(new FinancialPlanContainsKeywordsPredicate(financialPlanKeywords)); + + return new FindCommand(combinedPredicate); } } diff --git a/src/main/java/seedu/address/logic/parser/GatherCommandParser.java b/src/main/java/seedu/address/logic/parser/GatherCommandParser.java index 0b0f00d5ded..57dd605ab20 100644 --- a/src/main/java/seedu/address/logic/parser/GatherCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/GatherCommandParser.java @@ -3,6 +3,8 @@ import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT; import static seedu.address.logic.parser.CliSyntax.PREFIX_FINANCIAL_PLAN; import static seedu.address.logic.parser.CliSyntax.PREFIX_TAG; +import static seedu.address.logic.parser.ParserUtil.validateFinancialPlan; +import static seedu.address.logic.parser.ParserUtil.validateTag; import seedu.address.logic.commands.GatherCommand; import seedu.address.logic.parser.exceptions.ParseException; @@ -45,19 +47,4 @@ public GatherCommand parse(String args) throws ParseException { throw new ParseException( String.format(MESSAGE_INVALID_COMMAND_FORMAT, GatherCommand.MESSAGE_USAGE)); } - - private void validateFinancialPlan(String input) throws ParseException { - if (input.isEmpty() || !FinancialPlan.isValidFinancialPlanName(input)) { - throw new ParseException( - String.format(MESSAGE_INVALID_COMMAND_FORMAT, FinancialPlan.MESSAGE_CONSTRAINTS)); - } - } - - private void validateTag(String input) throws ParseException { - if (input.isEmpty() || !Tag.isValidTagName(input)) { - throw new ParseException( - String.format(MESSAGE_INVALID_COMMAND_FORMAT, Tag.MESSAGE_CONSTRAINTS)); - } - } - } diff --git a/src/main/java/seedu/address/logic/parser/ParserUtil.java b/src/main/java/seedu/address/logic/parser/ParserUtil.java index fcb750b1755..96fa9c71906 100644 --- a/src/main/java/seedu/address/logic/parser/ParserUtil.java +++ b/src/main/java/seedu/address/logic/parser/ParserUtil.java @@ -1,11 +1,13 @@ package seedu.address.logic.parser; import static java.util.Objects.requireNonNull; +import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT; import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Set; import seedu.address.commons.core.index.Index; @@ -22,7 +24,7 @@ import seedu.address.model.tag.Tag; /** - * Contains utility methods used for parsing strings in the various *Parser classes. + * Contains utility methods used for parsing and validating strings in the various *Parser classes. */ public class ParserUtil { public static final String MESSAGE_INVALID_INDEX = "Index is not a non-zero unsigned integer."; @@ -215,4 +217,74 @@ public static Set parseTags(Collection tags) throws ParseException } return tagSet; } + /** + * Validates if a {@code String name} is a valid {@code Name}. + * + * @param input String to validate. + * @throws ParseException if the given string is invalid. + */ + public static void validateName(String input) throws ParseException { + if (input.isEmpty() || !Name.isValidName(input)) { + throw new ParseException( + String.format(MESSAGE_INVALID_COMMAND_FORMAT, Name.MESSAGE_CONSTRAINTS)); + } + } + /** + * Validates if a list of {@code String names} are valid for {@code Name} objects. + * + * @param inputs List of names to validate. + * @throws ParseException if any of the given names are invalid. + */ + public static void validateNames(List inputs) throws ParseException { + for (String name : inputs) { + validateName(name); + } + } + /** + * Validates if a {@code String financial plan} is a valid name for a {@code FinancialPlan}. + * + * @param input String to validate. + * @throws ParseException if the given string is invalid. + */ + public static void validateFinancialPlan(String input) throws ParseException { + if (input.isEmpty() || !FinancialPlan.isValidFinancialPlanName(input)) { + throw new ParseException( + String.format(MESSAGE_INVALID_COMMAND_FORMAT, FinancialPlan.MESSAGE_CONSTRAINTS)); + } + } + /** + * Validates if a list of {@code String financial plans} are valid as {@code FinancialPlan} names. + * + * @param inputs List of financial plan names to validate. + * @throws ParseException if any of the given names are invalid. + */ + public static void validateFinancialPlans(List inputs) throws ParseException { + for (String financialPlan : inputs) { + validateFinancialPlan(financialPlan); + } + } + + /** + * Validates if a {@code String tag} is a valid name for a {@code Tag}. + * + * @param input String to validate. + * @throws ParseException if the given string is invalid. + */ + public static void validateTag(String input) throws ParseException { + if (input.isEmpty() || !Tag.isValidTagName(input)) { + throw new ParseException( + String.format(MESSAGE_INVALID_COMMAND_FORMAT, Tag.MESSAGE_CONSTRAINTS)); + } + } + /** + * Validates if a list of {@code String tags} are valid as {@code Tag} names. + * + * @param inputs List of tag names to validate. + * @throws ParseException if any of the given names are invalid. + */ + public static void validateTags(List inputs) throws ParseException { + for (String tag : inputs) { + validateTag(tag); + } + } } diff --git a/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java new file mode 100644 index 00000000000..aab323c91a4 --- /dev/null +++ b/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java @@ -0,0 +1,49 @@ +package seedu.address.model.person.predicates; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.financialplan.FinancialPlan; +import seedu.address.model.person.Person; + +/** + * Tests that at least one of a {@code Person}'s {@code FinancialPlan} matches any of the keywords given. + */ +public class FinancialPlanContainsKeywordsPredicate implements Predicate { + private final List keywords; + + public FinancialPlanContainsKeywordsPredicate(List keywords) { + this.keywords = keywords; + } + + @Override + public boolean test(Person person) { + Stream financialPlanStream = person.getFinancialPlans().stream(); + // We check for each financial plan if it contains a keyword as a substring + return financialPlanStream.anyMatch(financialPlan -> keywords.stream() + .anyMatch(keyword -> financialPlan.containsSubstring(keyword))); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + // instanceof handles nulls + if (!(other instanceof FinancialPlanContainsKeywordsPredicate)) { + return false; + } + + FinancialPlanContainsKeywordsPredicate otherNameContainsKeywordsPredicate = + (FinancialPlanContainsKeywordsPredicate) other; + return keywords.equals(otherNameContainsKeywordsPredicate.keywords); + } + + @Override + public String toString() { + return new ToStringBuilder(this).add("keywords", keywords).toString(); + } +} diff --git a/src/main/java/seedu/address/model/person/NameContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java similarity index 93% rename from src/main/java/seedu/address/model/person/NameContainsKeywordsPredicate.java rename to src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java index 62d19be2977..0a040a7f3c7 100644 --- a/src/main/java/seedu/address/model/person/NameContainsKeywordsPredicate.java +++ b/src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java @@ -1,10 +1,11 @@ -package seedu.address.model.person; +package seedu.address.model.person.predicates; import java.util.List; import java.util.function.Predicate; import seedu.address.commons.util.StringUtil; import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.person.Person; /** * Tests that a {@code Person}'s {@code Name} matches any of the keywords given. diff --git a/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java new file mode 100644 index 00000000000..e3ade77eacd --- /dev/null +++ b/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java @@ -0,0 +1,48 @@ +package seedu.address.model.person.predicates; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.person.Person; +import seedu.address.model.tag.Tag; + +/** + * Tests that at least one of a {@code Person}'s {@code Tag} matches any of the keywords given. + */ +public class TagContainsKeywordsPredicate implements Predicate { + private final List keywords; + + public TagContainsKeywordsPredicate(List keywords) { + this.keywords = keywords; + } + + @Override + public boolean test(Person person) { + Stream tagStream = person.getTags().stream(); + // We check for each tag if it contains a keyword as a substring + return tagStream.anyMatch(tag -> keywords.stream() + .anyMatch(keyword -> tag.containsSubstring(keyword))); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + // instanceof handles nulls + if (!(other instanceof TagContainsKeywordsPredicate)) { + return false; + } + + TagContainsKeywordsPredicate otherNameContainsKeywordsPredicate = (TagContainsKeywordsPredicate) other; + return keywords.equals(otherNameContainsKeywordsPredicate.keywords); + } + + @Override + public String toString() { + return new ToStringBuilder(this).add("keywords", keywords).toString(); + } +} diff --git a/src/main/java/seedu/address/model/tag/Tag.java b/src/main/java/seedu/address/model/tag/Tag.java index efee32ec505..30ec3d188df 100644 --- a/src/main/java/seedu/address/model/tag/Tag.java +++ b/src/main/java/seedu/address/model/tag/Tag.java @@ -24,7 +24,6 @@ public Tag(String tagName) { checkArgument(isValidTagName(tagName), MESSAGE_CONSTRAINTS); this.tagName = tagName; } - /** * Returns true if a given string is a valid tag name. */ diff --git a/src/main/java/seedu/address/ui/MainWindow.java b/src/main/java/seedu/address/ui/MainWindow.java index d3fed1368a4..780bae461f9 100644 --- a/src/main/java/seedu/address/ui/MainWindow.java +++ b/src/main/java/seedu/address/ui/MainWindow.java @@ -68,9 +68,7 @@ public MainWindow(Stage primaryStage, Logic logic) { // Configure the UI setWindowDefaultSize(logic.getGuiSettings()); - setAccelerators(); - helpWindow = new HelpWindow(); clearWindow = new ClearWindow(this::executeCommand); } diff --git a/src/test/java/seedu/address/logic/commands/CommandTestUtil.java b/src/test/java/seedu/address/logic/commands/CommandTestUtil.java index 957a53c89d3..bb7e5d1588f 100644 --- a/src/test/java/seedu/address/logic/commands/CommandTestUtil.java +++ b/src/test/java/seedu/address/logic/commands/CommandTestUtil.java @@ -22,7 +22,7 @@ import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.model.AddressBook; import seedu.address.model.Model; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.Person; import seedu.address.testutil.EditPersonDescriptorBuilder; diff --git a/src/test/java/seedu/address/logic/commands/FindCommandTest.java b/src/test/java/seedu/address/logic/commands/FindCommandTest.java index b8b7dbba91a..99063b44d6e 100644 --- a/src/test/java/seedu/address/logic/commands/FindCommandTest.java +++ b/src/test/java/seedu/address/logic/commands/FindCommandTest.java @@ -18,7 +18,7 @@ import seedu.address.model.Model; import seedu.address.model.ModelManager; import seedu.address.model.UserPrefs; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; /** * Contains integration tests (interaction with the Model) for {@code FindCommand}. diff --git a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java b/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java index f454873373d..14454637e2f 100644 --- a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java +++ b/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java @@ -28,7 +28,7 @@ import seedu.address.logic.commands.HelpCommand; import seedu.address.logic.commands.ListCommand; import seedu.address.logic.parser.exceptions.ParseException; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.Person; import seedu.address.model.person.gatheremail.GatherEmailByFinancialPlan; import seedu.address.model.person.gatheremail.GatherEmailByTag; diff --git a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java index d92e64d12f9..24f79ab9823 100644 --- a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java +++ b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test; import seedu.address.logic.commands.FindCommand; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; public class FindCommandParserTest { diff --git a/src/test/java/seedu/address/model/ModelManagerTest.java b/src/test/java/seedu/address/model/ModelManagerTest.java index 1fc5de1e0ef..e8985a2f053 100644 --- a/src/test/java/seedu/address/model/ModelManagerTest.java +++ b/src/test/java/seedu/address/model/ModelManagerTest.java @@ -19,7 +19,7 @@ import seedu.address.commons.core.GuiSettings; import seedu.address.model.financialplan.FinancialPlan; -import seedu.address.model.person.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.gatheremail.GatherEmailByFinancialPlan; import seedu.address.model.person.gatheremail.GatherEmailByTag; import seedu.address.testutil.AddressBookBuilder; diff --git a/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java index 6b3fd90ade7..da911c1b796 100644 --- a/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java +++ b/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.testutil.PersonBuilder; public class NameContainsKeywordsPredicateTest { From c20999c13a41745e2087dd2ea5b57e70c67e1273 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 01:06:59 +0800 Subject: [PATCH 02/11] Add CombinedPredicate class and tests for previous commit The current predicate implementation makes it difficult to determine if two predicates for a Person are the same. This implementation makes it slightly easier to compare, such that as long as the keywords within each component predicate are ordered in a certain way, then two CombinedPredicates will be the same. It's good enough for the current purpose anyway. --- .../logic/parser/FindCommandParser.java | 14 ++- .../logic/parser/GatherCommandParser.java | 2 - .../person/predicates/CombinedPredicate.java | 78 +++++++++++++ .../logic/commands/CommandTestUtil.java | 3 +- .../logic/commands/FindCommandTest.java | 37 +++--- .../logic/parser/AddressBookParserTest.java | 17 ++- .../logic/parser/FindCommandParserTest.java | 20 +++- .../address/logic/parser/ParserUtilTest.java | 89 +++++++++++++- .../seedu/address/model/ModelManagerTest.java | 2 +- .../predicates/CombinedPredicateTest.java | 110 ++++++++++++++++++ ...cialPlanContainsKeywordsPredicateTest.java | 95 +++++++++++++++ .../NameContainsKeywordsPredicateTest.java | 3 +- .../TagContainsKeywordsPredicateTest.java | 88 ++++++++++++++ 13 files changed, 517 insertions(+), 41 deletions(-) create mode 100644 src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java create mode 100644 src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java create mode 100644 src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java rename src/test/java/seedu/address/model/person/{ => predicates}/NameContainsKeywordsPredicateTest.java (97%) create mode 100644 src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java diff --git a/src/main/java/seedu/address/logic/parser/FindCommandParser.java b/src/main/java/seedu/address/logic/parser/FindCommandParser.java index a81a39ccf99..05e800a0703 100644 --- a/src/main/java/seedu/address/logic/parser/FindCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/FindCommandParser.java @@ -9,11 +9,10 @@ import static seedu.address.logic.parser.ParserUtil.validateTags; import java.util.List; -import java.util.function.Predicate; import seedu.address.logic.commands.FindCommand; import seedu.address.logic.parser.exceptions.ParseException; -import seedu.address.model.person.Person; +import seedu.address.model.person.predicates.CombinedPredicate; import seedu.address.model.person.predicates.FinancialPlanContainsKeywordsPredicate; import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.predicates.TagContainsKeywordsPredicate; @@ -38,17 +37,22 @@ public FindCommand parse(String args) throws ParseException { PREFIX_FINANCIAL_PLAN, PREFIX_TAG); List nameKeywords = argMultimap.getAllValues(PREFIX_NAME); + nameKeywords.replaceAll(name -> name.trim()); validateNames(nameKeywords); List tagKeywords = argMultimap.getAllValues(PREFIX_TAG); + tagKeywords.replaceAll(tag -> tag.trim()); validateTags(tagKeywords); List financialPlanKeywords = argMultimap.getAllValues(PREFIX_FINANCIAL_PLAN); + financialPlanKeywords.replaceAll(financialPlan -> financialPlan.trim()); validateFinancialPlans(financialPlanKeywords); - Predicate combinedPredicate = new NameContainsKeywordsPredicate(nameKeywords) - .or(new TagContainsKeywordsPredicate(tagKeywords)) - .or(new FinancialPlanContainsKeywordsPredicate(financialPlanKeywords)); + CombinedPredicate combinedPredicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(financialPlanKeywords), + new NameContainsKeywordsPredicate(nameKeywords), + new TagContainsKeywordsPredicate(tagKeywords) + ); return new FindCommand(combinedPredicate); } diff --git a/src/main/java/seedu/address/logic/parser/GatherCommandParser.java b/src/main/java/seedu/address/logic/parser/GatherCommandParser.java index 57dd605ab20..f58c802741c 100644 --- a/src/main/java/seedu/address/logic/parser/GatherCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/GatherCommandParser.java @@ -8,10 +8,8 @@ import seedu.address.logic.commands.GatherCommand; import seedu.address.logic.parser.exceptions.ParseException; -import seedu.address.model.financialplan.FinancialPlan; import seedu.address.model.person.gatheremail.GatherEmailByFinancialPlan; import seedu.address.model.person.gatheremail.GatherEmailByTag; -import seedu.address.model.tag.Tag; /** * Parses input arguments and creates a new GatherCommand object diff --git a/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java b/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java new file mode 100644 index 00000000000..977c74a1e5c --- /dev/null +++ b/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java @@ -0,0 +1,78 @@ +package seedu.address.model.person.predicates; + +import java.util.Objects; +import java.util.function.Predicate; + +import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.person.Person; + +/** + * Tests that at least one of a {@code Person}'s {@code FinancialPlan}, {@code Name} or {@code Tag} matches any + * of the keywords given. Note that there is no requirement that all 3 predicates must be present in the constructor. + */ +public class CombinedPredicate implements Predicate { + public final FinancialPlanContainsKeywordsPredicate financialPlanContainsKeywordsPredicate; + public final NameContainsKeywordsPredicate nameContainsKeywordsPredicate; + public final TagContainsKeywordsPredicate tagContainsKeywordsPredicate; + + /** + * Creates a combined predicate containing up to one of a financial plan predicate, name predicate and tag + * predicate each. + * + * @param financialPlanContainsKeywordsPredicate Financial plan predicate. + * @param nameContainsKeywordsPredicate Name predicate. + * @param tagContainsKeywordsPredicate Tag predicate. + */ + public CombinedPredicate(FinancialPlanContainsKeywordsPredicate financialPlanContainsKeywordsPredicate, + NameContainsKeywordsPredicate nameContainsKeywordsPredicate, + TagContainsKeywordsPredicate tagContainsKeywordsPredicate) { + this.financialPlanContainsKeywordsPredicate = financialPlanContainsKeywordsPredicate; + this.nameContainsKeywordsPredicate = nameContainsKeywordsPredicate; + this.tagContainsKeywordsPredicate = tagContainsKeywordsPredicate; + } + + @Override + public boolean test(Person person) { + boolean result = false; + if (financialPlanContainsKeywordsPredicate != null) { + result = result || financialPlanContainsKeywordsPredicate.test(person); + } + if (nameContainsKeywordsPredicate != null) { + result = result || nameContainsKeywordsPredicate.test(person); + } + if (tagContainsKeywordsPredicate != null) { + result = result || tagContainsKeywordsPredicate.test(person); + } + return result; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + // instanceof handles nulls + if (!(other instanceof CombinedPredicate)) { + return false; + } + + CombinedPredicate otherNameContainsKeywordsPredicate = + (CombinedPredicate) other; + return Objects.equals(financialPlanContainsKeywordsPredicate, + otherNameContainsKeywordsPredicate.financialPlanContainsKeywordsPredicate) + && Objects.equals(nameContainsKeywordsPredicate, + otherNameContainsKeywordsPredicate.nameContainsKeywordsPredicate) + && Objects.equals(tagContainsKeywordsPredicate, + otherNameContainsKeywordsPredicate.tagContainsKeywordsPredicate); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .add("financialPlanContainsKeywordsPredicate", financialPlanContainsKeywordsPredicate) + .add("nameContainsKeywordsPredicate", nameContainsKeywordsPredicate) + .add("tagContainsKeywordsPredicate", tagContainsKeywordsPredicate) + .toString(); + } +} diff --git a/src/test/java/seedu/address/logic/commands/CommandTestUtil.java b/src/test/java/seedu/address/logic/commands/CommandTestUtil.java index bb7e5d1588f..91aa2865c66 100644 --- a/src/test/java/seedu/address/logic/commands/CommandTestUtil.java +++ b/src/test/java/seedu/address/logic/commands/CommandTestUtil.java @@ -22,8 +22,8 @@ import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.model.AddressBook; import seedu.address.model.Model; -import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.Person; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.testutil.EditPersonDescriptorBuilder; /** @@ -43,6 +43,7 @@ public class CommandTestUtil { public static final String VALID_NEXT_OF_KIN_NAME_BOB = "Bob Dad"; public static final String VALID_NEXT_OF_KIN_PHONE_AMY = "33333333"; public static final String VALID_NEXT_OF_KIN_PHONE_BOB = "44444444"; + public static final String VALID_TAG_HUSBAND = "husband"; public static final String VALID_TAG_FRIEND = "friend"; public static final String VALID_FINANCIAL_PLAN_1 = "financial plan 1"; diff --git a/src/test/java/seedu/address/logic/commands/FindCommandTest.java b/src/test/java/seedu/address/logic/commands/FindCommandTest.java index 99063b44d6e..3e7d98fb21a 100644 --- a/src/test/java/seedu/address/logic/commands/FindCommandTest.java +++ b/src/test/java/seedu/address/logic/commands/FindCommandTest.java @@ -12,13 +12,16 @@ import java.util.Arrays; import java.util.Collections; +import java.util.function.Predicate; import org.junit.jupiter.api.Test; import seedu.address.model.Model; import seedu.address.model.ModelManager; import seedu.address.model.UserPrefs; +import seedu.address.model.person.Person; import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.TagContainsKeywordsPredicate; /** * Contains integration tests (interaction with the Model) for {@code FindCommand}. @@ -29,13 +32,19 @@ public class FindCommandTest { @Test public void equals() { - NameContainsKeywordsPredicate firstPredicate = - new NameContainsKeywordsPredicate(Collections.singletonList("first")); - NameContainsKeywordsPredicate secondPredicate = - new NameContainsKeywordsPredicate(Collections.singletonList("second")); + Predicate firstPredicate = + new NameContainsKeywordsPredicate(Collections.singletonList("first")) + .or(new TagContainsKeywordsPredicate(Collections.singletonList("firstTag"))); + Predicate secondPredicate = + new NameContainsKeywordsPredicate(Collections.singletonList("second")) + .or(new TagContainsKeywordsPredicate(Collections.singletonList("firstTag"))); + Predicate firstPredicateAgain = + new TagContainsKeywordsPredicate(Collections.singletonList("firstTag")) + .or(new NameContainsKeywordsPredicate(Collections.singletonList("first"))); FindCommand findFirstCommand = new FindCommand(firstPredicate); FindCommand findSecondCommand = new FindCommand(secondPredicate); + FindCommand findFirstCommandAgain = new FindCommand(firstPredicateAgain); // same object -> returns true assertTrue(findFirstCommand.equals(findFirstCommand)); @@ -44,6 +53,9 @@ public void equals() { FindCommand findFirstCommandCopy = new FindCommand(firstPredicate); assertTrue(findFirstCommand.equals(findFirstCommandCopy)); + // same values, composed differently -> returns true + assertFalse(findFirstCommand.equals(findFirstCommandAgain)); + // different types -> returns false assertFalse(findFirstCommand.equals(1)); @@ -54,20 +66,10 @@ public void equals() { assertFalse(findFirstCommand.equals(findSecondCommand)); } - @Test - public void execute_zeroKeywords_noPersonFound() { - String expectedMessage = String.format(MESSAGE_PERSONS_LISTED_OVERVIEW, 0); - NameContainsKeywordsPredicate predicate = preparePredicate(" "); - FindCommand command = new FindCommand(predicate); - expectedModel.updateFilteredPersonList(predicate); - assertCommandSuccess(command, model, expectedMessage, expectedModel); - assertEquals(Collections.emptyList(), model.getFilteredPersonList()); - } - @Test public void execute_multipleKeywords_multiplePersonsFound() { String expectedMessage = String.format(MESSAGE_PERSONS_LISTED_OVERVIEW, 3); - NameContainsKeywordsPredicate predicate = preparePredicate("Kurz Elle Kunz"); + NameContainsKeywordsPredicate predicate = prepareNamePredicate("Kurz Elle Kunz"); FindCommand command = new FindCommand(predicate); expectedModel.updateFilteredPersonList(predicate); assertCommandSuccess(command, model, expectedMessage, expectedModel); @@ -76,7 +78,8 @@ public void execute_multipleKeywords_multiplePersonsFound() { @Test public void toStringMethod() { - NameContainsKeywordsPredicate predicate = new NameContainsKeywordsPredicate(Arrays.asList("keyword")); + Predicate predicate = new NameContainsKeywordsPredicate(Arrays.asList("keyword")) + .or(new TagContainsKeywordsPredicate(Collections.singletonList("tag"))); FindCommand findCommand = new FindCommand(predicate); String expected = FindCommand.class.getCanonicalName() + "{predicate=" + predicate + "}"; assertEquals(expected, findCommand.toString()); @@ -85,7 +88,7 @@ public void toStringMethod() { /** * Parses {@code userInput} into a {@code NameContainsKeywordsPredicate}. */ - private NameContainsKeywordsPredicate preparePredicate(String userInput) { + private NameContainsKeywordsPredicate prepareNamePredicate(String userInput) { return new NameContainsKeywordsPredicate(Arrays.asList(userInput.split("\\s+"))); } } diff --git a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java b/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java index 14454637e2f..a462a5a1c64 100644 --- a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java +++ b/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java @@ -8,6 +8,7 @@ import static seedu.address.logic.commands.CommandTestUtil.TAG_DESC_HUSBAND; import static seedu.address.logic.commands.CommandTestUtil.VALID_FINANCIAL_PLAN_1; import static seedu.address.logic.commands.CommandTestUtil.VALID_TAG_HUSBAND; +import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME; import static seedu.address.testutil.Assert.assertThrows; import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON; @@ -28,10 +29,13 @@ import seedu.address.logic.commands.HelpCommand; import seedu.address.logic.commands.ListCommand; import seedu.address.logic.parser.exceptions.ParseException; -import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.Person; import seedu.address.model.person.gatheremail.GatherEmailByFinancialPlan; import seedu.address.model.person.gatheremail.GatherEmailByTag; +import seedu.address.model.person.predicates.CombinedPredicate; +import seedu.address.model.person.predicates.FinancialPlanContainsKeywordsPredicate; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.TagContainsKeywordsPredicate; import seedu.address.testutil.EditPersonDescriptorBuilder; import seedu.address.testutil.PersonBuilder; import seedu.address.testutil.PersonUtil; @@ -81,8 +85,15 @@ public void parseCommand_exit() throws Exception { public void parseCommand_find() throws Exception { List keywords = Arrays.asList("foo", "bar", "baz"); FindCommand command = (FindCommand) parser.parseCommand( - FindCommand.COMMAND_WORD + " " + keywords.stream().collect(Collectors.joining(" "))); - assertEquals(new FindCommand(new NameContainsKeywordsPredicate(keywords)), command); + FindCommand.COMMAND_WORD + " " + + keywords.stream().map(name -> PREFIX_NAME + name).collect(Collectors.joining(" "))); + assertEquals( + new FindCommand( + new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(List.of()), + new NameContainsKeywordsPredicate(keywords), + new TagContainsKeywordsPredicate(List.of()))), + command); } @Test diff --git a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java index 24f79ab9823..2effd3fb657 100644 --- a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java +++ b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java @@ -1,15 +1,20 @@ package seedu.address.logic.parser; import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT; +import static seedu.address.logic.parser.CliSyntax.PREFIX_NAME; import static seedu.address.logic.parser.CommandParserTestUtil.assertParseFailure; import static seedu.address.logic.parser.CommandParserTestUtil.assertParseSuccess; import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.Test; import seedu.address.logic.commands.FindCommand; +import seedu.address.model.person.predicates.CombinedPredicate; +import seedu.address.model.person.predicates.FinancialPlanContainsKeywordsPredicate; import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; +import seedu.address.model.person.predicates.TagContainsKeywordsPredicate; public class FindCommandParserTest { @@ -17,18 +22,23 @@ public class FindCommandParserTest { @Test public void parse_emptyArg_throwsParseException() { - assertParseFailure(parser, " ", String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); + assertParseFailure(parser, " ", + String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); } @Test public void parse_validArgs_returnsFindCommand() { // no leading and trailing whitespaces FindCommand expectedFindCommand = - new FindCommand(new NameContainsKeywordsPredicate(Arrays.asList("Alice", "Bob"))); - assertParseSuccess(parser, "Alice Bob", expectedFindCommand); + new FindCommand(new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(List.of()), + new NameContainsKeywordsPredicate(Arrays.asList("Alice", "Bob")), + new TagContainsKeywordsPredicate(List.of()))); + String rawFindCommand = " " + PREFIX_NAME + "Alice " + PREFIX_NAME + "Bob"; + assertParseSuccess(parser, rawFindCommand, expectedFindCommand); // multiple whitespaces between keywords - assertParseSuccess(parser, " \n Alice \n \t Bob \t", expectedFindCommand); + rawFindCommand = " " + PREFIX_NAME + " \n Alice \n " + PREFIX_NAME + " \t Bob \t"; + assertParseSuccess(parser, rawFindCommand, expectedFindCommand); } - } diff --git a/src/test/java/seedu/address/logic/parser/ParserUtilTest.java b/src/test/java/seedu/address/logic/parser/ParserUtilTest.java index 60ef6026094..fddb6123b7a 100644 --- a/src/test/java/seedu/address/logic/parser/ParserUtilTest.java +++ b/src/test/java/seedu/address/logic/parser/ParserUtilTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static seedu.address.logic.parser.ParserUtil.MESSAGE_INVALID_INDEX; import static seedu.address.testutil.Assert.assertThrows; import static seedu.address.testutil.TypicalIndexes.INDEX_FIRST_PERSON; @@ -28,12 +29,16 @@ public class ParserUtilTest { private static final String INVALID_ADDRESS = " "; private static final String INVALID_EMAIL = "example.com"; private static final String INVALID_TAG = "#friend"; - private static final String VALID_NAME = "Rachel Walker"; + private static final String INVALID_FINANCIAL_PLAN = "Plan !!"; + private static final String VALID_NAME_1 = "Rachel Walker"; + private static final String VALID_NAME_2 = "Captain Kek"; private static final String VALID_PHONE = "123456"; private static final String VALID_ADDRESS = "123 Main Street #0505"; private static final String VALID_EMAIL = "rachel@example.com"; private static final String VALID_TAG_1 = "friend"; private static final String VALID_TAG_2 = "neighbour"; + private static final String VALID_FINANCIAL_PLAN_1 = "Plan A"; + private static final String VALID_FINANCIAL_PLAN_2 = "Plan B"; private static final String VALID_APPOINTMENT_DESC = "Review Insurance"; private static final String VALID_APPOINTMENT_DATE = "01-01-2023 20:00"; private static final String WHITESPACE = " \t\r\n"; @@ -72,14 +77,14 @@ public void parseName_invalidValue_throwsParseException() { @Test public void parseName_validValueWithoutWhitespace_returnsName() throws Exception { - Name expectedName = new Name(VALID_NAME); - assertEquals(expectedName, ParserUtil.parseName(VALID_NAME)); + Name expectedName = new Name(VALID_NAME_1); + assertEquals(expectedName, ParserUtil.parseName(VALID_NAME_1)); } @Test public void parseName_validValueWithWhitespace_returnsTrimmedName() throws Exception { - String nameWithWhitespace = WHITESPACE + VALID_NAME + WHITESPACE; - Name expectedName = new Name(VALID_NAME); + String nameWithWhitespace = WHITESPACE + VALID_NAME_1 + WHITESPACE; + Name expectedName = new Name(VALID_NAME_1); assertEquals(expectedName, ParserUtil.parseName(nameWithWhitespace)); } @@ -222,4 +227,78 @@ public void parseAppointment_invalidInputs_throwsParseException() { assertThrows(ParseException.class, () -> ParserUtil.parseAppointment(VALID_APPOINTMENT_DESC, INVALID_APPOINTMENT_DATE)); } + + @Test + public void validateName_validInput_success() { + try { + ParserUtil.validateName(VALID_NAME_1); + } catch (ParseException e) { + fail(); + } + } + @Test + public void validateName_invalidInput_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateName(INVALID_NAME)); + } + @Test + public void validateNames_validInputs_success() { + try { + ParserUtil.validateNames(Arrays.asList(VALID_NAME_1, VALID_NAME_2)); + } catch (ParseException e) { + fail(); + } + } + @Test + public void validateNames_invalidInputs_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateNames(Arrays.asList(VALID_NAME_1, INVALID_NAME))); + } + @Test + public void validateTag_validInput_success() { + try { + ParserUtil.validateTag(VALID_TAG_1); + } catch (ParseException e) { + fail(); + } + } + @Test + public void validateTag_invalidInput_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateTag(INVALID_TAG)); + } + @Test + public void validateTags_validInputs_success() { + try { + ParserUtil.validateTags(Arrays.asList(VALID_TAG_1, VALID_TAG_2)); + } catch (ParseException e) { + fail(); + } + } + @Test + public void validateTags_invalidInputs_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateTags(Arrays.asList(VALID_NAME_1, INVALID_TAG))); + } + @Test + public void validateFinancialPlan_validInput_success() { + try { + ParserUtil.validateFinancialPlan(VALID_FINANCIAL_PLAN_1); + } catch (ParseException e) { + fail(); + } + } + @Test + public void validateFinancialPlan_invalidInput_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateFinancialPlan(INVALID_FINANCIAL_PLAN)); + } + @Test + public void validateFinancialPlans_validInputs_success() { + try { + ParserUtil.validateFinancialPlans(Arrays.asList(VALID_FINANCIAL_PLAN_1, VALID_FINANCIAL_PLAN_2)); + } catch (ParseException e) { + fail(); + } + } + @Test + public void validateFinancialPlans_invalidInputs_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateFinancialPlans( + Arrays.asList(INVALID_FINANCIAL_PLAN, VALID_FINANCIAL_PLAN_1))); + } } diff --git a/src/test/java/seedu/address/model/ModelManagerTest.java b/src/test/java/seedu/address/model/ModelManagerTest.java index e8985a2f053..c686bace861 100644 --- a/src/test/java/seedu/address/model/ModelManagerTest.java +++ b/src/test/java/seedu/address/model/ModelManagerTest.java @@ -19,9 +19,9 @@ import seedu.address.commons.core.GuiSettings; import seedu.address.model.financialplan.FinancialPlan; -import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.gatheremail.GatherEmailByFinancialPlan; import seedu.address.model.person.gatheremail.GatherEmailByTag; +import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.testutil.AddressBookBuilder; public class ModelManagerTest { diff --git a/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java new file mode 100644 index 00000000000..0193952a8d5 --- /dev/null +++ b/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java @@ -0,0 +1,110 @@ +package seedu.address.model.person.predicates; + +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.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import seedu.address.testutil.PersonBuilder; + +public class CombinedPredicateTest { + @Test + public void equals() { + List firstPredicateKeywordList = Collections.singletonList("first"); + List secondPredicateKeywordList = Arrays.asList("first", "second"); + + CombinedPredicate firstPredicate = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList), + null, null); + CombinedPredicate secondPredicate = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(secondPredicateKeywordList), + null, null); + + // same object -> returns true + assertTrue(firstPredicate.equals(firstPredicate)); + + // same values -> returns true + CombinedPredicate firstPredicateCopy = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList), + null, null); + assertTrue(firstPredicate.equals(firstPredicateCopy)); + + // different types -> returns false + assertFalse(firstPredicate.equals(1)); + + // null -> returns false + assertFalse(firstPredicate.equals(null)); + + // different financial plan -> returns false + assertFalse(firstPredicate.equals(secondPredicate)); + } + + @Test + public void test_personContainsKeywords_returnsTrue() { + // One keyword + CombinedPredicate predicate = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), + null, null); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior").build())); + + // Multiple keywords for same person + predicate = new CombinedPredicate(null, + new NameContainsKeywordsPredicate(Arrays.asList("Alice", "Bob")), null); + assertTrue(predicate.test(new PersonBuilder().withName("Alice Bob").build())); + + // Multiple keywords in different fields + predicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), + new NameContainsKeywordsPredicate(Collections.singletonList("Alice")), null); + assertTrue(predicate.test(new PersonBuilder().withName("Alice").withFinancialPlans("Senior").build())); + + // Only one matching keyword + predicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), + new NameContainsKeywordsPredicate(Collections.singletonList("Alice")), null); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior", "Koopa").build())); + + // Mixed-case keywords + predicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("chILD")), + null, null); + assertTrue(predicate.test(new PersonBuilder().withName("Alice").withFinancialPlans("Child Premium").build())); + } + + @Test + public void test_personDoesNotContainKeywords_returnsFalse() { + // Zero keywords + CombinedPredicate predicate = + new CombinedPredicate(null, null, null); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").build())); + + // Non-matching keyword + predicate = new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior")), + null, null); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); + + // Keywords match phone, email and address, but does not match financial plans + predicate = new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate( + Arrays.asList("12345", "alice@email.com", "Main", "Street")), null, null); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").withPhone("12345") + .withEmail("alice@email.com").withAddress("Main Street").build())); + } + + @Test + public void toStringMethod() { + List keywords = List.of("keyword1", "keyword2"); + CombinedPredicate predicate = new CombinedPredicate(null, + new NameContainsKeywordsPredicate(keywords), null); + + String expected = CombinedPredicate.class.getCanonicalName() + + "{financialPlanContainsKeywordsPredicate=" + null + ", " + + "nameContainsKeywordsPredicate=" + predicate.nameContainsKeywordsPredicate.toString() + ", " + + "tagContainsKeywordsPredicate=" + null + "}"; + assertEquals(expected, predicate.toString()); + } +} diff --git a/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java new file mode 100644 index 00000000000..cc6d6c5d519 --- /dev/null +++ b/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java @@ -0,0 +1,95 @@ +package seedu.address.model.person.predicates; + +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.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import seedu.address.testutil.PersonBuilder; + +public class FinancialPlanContainsKeywordsPredicateTest { + @Test + public void equals() { + List firstPredicateKeywordList = Collections.singletonList("first"); + List secondPredicateKeywordList = Arrays.asList("first", "second"); + + FinancialPlanContainsKeywordsPredicate firstPredicate = + new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList); + FinancialPlanContainsKeywordsPredicate secondPredicate = + new FinancialPlanContainsKeywordsPredicate(secondPredicateKeywordList); + + // same object -> returns true + assertTrue(firstPredicate.equals(firstPredicate)); + + // same values -> returns true + FinancialPlanContainsKeywordsPredicate firstPredicateCopy = + new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList); + assertTrue(firstPredicate.equals(firstPredicateCopy)); + + // different types -> returns false + assertFalse(firstPredicate.equals(1)); + + // null -> returns false + assertFalse(firstPredicate.equals(null)); + + // different financial plan -> returns false + assertFalse(firstPredicate.equals(secondPredicate)); + } + + @Test + public void test_financialPlansContainKeywords_returnsTrue() { + // One keyword + FinancialPlanContainsKeywordsPredicate predicate = + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior").build())); + + // Multiple keywords in same financial plan + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior Premium").build())); + + // Multiple keywords in different financial plans + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior", "Premium").build())); + + // Only one matching keyword + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Child", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Child", "Koopa").build())); + + // Mixed-case keywords + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("cHIld", "pREmium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); + } + + @Test + public void test_financialPlansDoNotContainKeywords_returnsFalse() { + // Zero keywords + FinancialPlanContainsKeywordsPredicate predicate = + new FinancialPlanContainsKeywordsPredicate(Collections.emptyList()); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").build())); + + // Non-matching keyword + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior")); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); + + // Keywords match phone, email and address, but does not match financial plans + predicate = new FinancialPlanContainsKeywordsPredicate( + Arrays.asList("12345", "alice@email.com", "Main", "Street")); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").withPhone("12345") + .withEmail("alice@email.com").withAddress("Main Street").build())); + } + + @Test + public void toStringMethod() { + List keywords = List.of("keyword1", "keyword2"); + FinancialPlanContainsKeywordsPredicate predicate = new FinancialPlanContainsKeywordsPredicate(keywords); + + String expected = FinancialPlanContainsKeywordsPredicate.class.getCanonicalName() + + "{keywords=" + keywords + "}"; + assertEquals(expected, predicate.toString()); + } +} diff --git a/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicateTest.java similarity index 97% rename from src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java rename to src/test/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicateTest.java index da911c1b796..8e038b559c1 100644 --- a/src/test/java/seedu/address/model/person/NameContainsKeywordsPredicateTest.java +++ b/src/test/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicateTest.java @@ -1,4 +1,4 @@ -package seedu.address.model.person; +package seedu.address.model.person.predicates; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -10,7 +10,6 @@ import org.junit.jupiter.api.Test; -import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.testutil.PersonBuilder; public class NameContainsKeywordsPredicateTest { diff --git a/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java new file mode 100644 index 00000000000..e1640b9d862 --- /dev/null +++ b/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java @@ -0,0 +1,88 @@ +package seedu.address.model.person.predicates; + +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.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import seedu.address.testutil.PersonBuilder; + +public class TagContainsKeywordsPredicateTest { + @Test + public void equals() { + List firstPredicateKeywordList = Collections.singletonList("first"); + List secondPredicateKeywordList = Arrays.asList("first", "second"); + + TagContainsKeywordsPredicate firstPredicate = new TagContainsKeywordsPredicate(firstPredicateKeywordList); + TagContainsKeywordsPredicate secondPredicate = new TagContainsKeywordsPredicate(secondPredicateKeywordList); + + // same object -> returns true + assertTrue(firstPredicate.equals(firstPredicate)); + + // same values -> returns true + TagContainsKeywordsPredicate firstPredicateCopy = new TagContainsKeywordsPredicate(firstPredicateKeywordList); + assertTrue(firstPredicate.equals(firstPredicateCopy)); + + // different types -> returns false + assertFalse(firstPredicate.equals(1)); + + // null -> returns false + assertFalse(firstPredicate.equals(null)); + + // different tag -> returns false + assertFalse(firstPredicate.equals(secondPredicate)); + } + + @Test + public void test_tagsContainKeywords_returnsTrue() { + // One keyword + TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(Collections.singletonList("Senior")); + assertTrue(predicate.test(new PersonBuilder().withTags("Senior").build())); + + // Multiple keywords in same tag + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withTags("SeniorPremium").build())); + + // Multiple keywords in different tags + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withTags("Senior", "Premium").build())); + + // Only one matching keyword + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Child", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withTags("Child", "Koopa").build())); + + // Mixed-case keywords + predicate = new TagContainsKeywordsPredicate(Arrays.asList("cHIld", "pREmium")); + assertTrue(predicate.test(new PersonBuilder().withTags("ChildPremium").build())); + } + + @Test + public void test_tagsDoNotContainKeywords_returnsFalse() { + // Zero keywords + TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(Collections.emptyList()); + assertFalse(predicate.test(new PersonBuilder().withTags("Child").build())); + + // Non-matching keyword + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior")); + assertFalse(predicate.test(new PersonBuilder().withTags("ChildPremium").build())); + + // Keywords match phone, email and address, but does not match tags + predicate = new TagContainsKeywordsPredicate(Arrays.asList("12345", "alice@email.com", "Main", "Street")); + assertFalse(predicate.test(new PersonBuilder().withTags("Child").withPhone("12345") + .withEmail("alice@email.com").withAddress("Main Street").build())); + } + + @Test + public void toStringMethod() { + List keywords = List.of("keyword1", "keyword2"); + TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(keywords); + + String expected = TagContainsKeywordsPredicate.class.getCanonicalName() + "{keywords=" + keywords + "}"; + assertEquals(expected, predicate.toString()); + } +} From 681ab8e0278bfd2654d0145c2a72893ede342bc8 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 01:15:38 +0800 Subject: [PATCH 03/11] Fix line separator --- .../person/predicates/CombinedPredicate.java | 156 ++++++------- ...inancialPlanContainsKeywordsPredicate.java | 98 ++++---- .../TagContainsKeywordsPredicate.java | 96 ++++---- .../predicates/CombinedPredicateTest.java | 220 +++++++++--------- ...cialPlanContainsKeywordsPredicateTest.java | 190 +++++++-------- .../TagContainsKeywordsPredicateTest.java | 176 +++++++------- 6 files changed, 468 insertions(+), 468 deletions(-) diff --git a/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java b/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java index 977c74a1e5c..5f2a7361756 100644 --- a/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java +++ b/src/main/java/seedu/address/model/person/predicates/CombinedPredicate.java @@ -1,78 +1,78 @@ -package seedu.address.model.person.predicates; - -import java.util.Objects; -import java.util.function.Predicate; - -import seedu.address.commons.util.ToStringBuilder; -import seedu.address.model.person.Person; - -/** - * Tests that at least one of a {@code Person}'s {@code FinancialPlan}, {@code Name} or {@code Tag} matches any - * of the keywords given. Note that there is no requirement that all 3 predicates must be present in the constructor. - */ -public class CombinedPredicate implements Predicate { - public final FinancialPlanContainsKeywordsPredicate financialPlanContainsKeywordsPredicate; - public final NameContainsKeywordsPredicate nameContainsKeywordsPredicate; - public final TagContainsKeywordsPredicate tagContainsKeywordsPredicate; - - /** - * Creates a combined predicate containing up to one of a financial plan predicate, name predicate and tag - * predicate each. - * - * @param financialPlanContainsKeywordsPredicate Financial plan predicate. - * @param nameContainsKeywordsPredicate Name predicate. - * @param tagContainsKeywordsPredicate Tag predicate. - */ - public CombinedPredicate(FinancialPlanContainsKeywordsPredicate financialPlanContainsKeywordsPredicate, - NameContainsKeywordsPredicate nameContainsKeywordsPredicate, - TagContainsKeywordsPredicate tagContainsKeywordsPredicate) { - this.financialPlanContainsKeywordsPredicate = financialPlanContainsKeywordsPredicate; - this.nameContainsKeywordsPredicate = nameContainsKeywordsPredicate; - this.tagContainsKeywordsPredicate = tagContainsKeywordsPredicate; - } - - @Override - public boolean test(Person person) { - boolean result = false; - if (financialPlanContainsKeywordsPredicate != null) { - result = result || financialPlanContainsKeywordsPredicate.test(person); - } - if (nameContainsKeywordsPredicate != null) { - result = result || nameContainsKeywordsPredicate.test(person); - } - if (tagContainsKeywordsPredicate != null) { - result = result || tagContainsKeywordsPredicate.test(person); - } - return result; - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - - // instanceof handles nulls - if (!(other instanceof CombinedPredicate)) { - return false; - } - - CombinedPredicate otherNameContainsKeywordsPredicate = - (CombinedPredicate) other; - return Objects.equals(financialPlanContainsKeywordsPredicate, - otherNameContainsKeywordsPredicate.financialPlanContainsKeywordsPredicate) - && Objects.equals(nameContainsKeywordsPredicate, - otherNameContainsKeywordsPredicate.nameContainsKeywordsPredicate) - && Objects.equals(tagContainsKeywordsPredicate, - otherNameContainsKeywordsPredicate.tagContainsKeywordsPredicate); - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .add("financialPlanContainsKeywordsPredicate", financialPlanContainsKeywordsPredicate) - .add("nameContainsKeywordsPredicate", nameContainsKeywordsPredicate) - .add("tagContainsKeywordsPredicate", tagContainsKeywordsPredicate) - .toString(); - } -} +package seedu.address.model.person.predicates; + +import java.util.Objects; +import java.util.function.Predicate; + +import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.person.Person; + +/** + * Tests that at least one of a {@code Person}'s {@code FinancialPlan}, {@code Name} or {@code Tag} matches any + * of the keywords given. Note that there is no requirement that all 3 predicates must be present in the constructor. + */ +public class CombinedPredicate implements Predicate { + public final FinancialPlanContainsKeywordsPredicate financialPlanContainsKeywordsPredicate; + public final NameContainsKeywordsPredicate nameContainsKeywordsPredicate; + public final TagContainsKeywordsPredicate tagContainsKeywordsPredicate; + + /** + * Creates a combined predicate containing up to one of a financial plan predicate, name predicate and tag + * predicate each. + * + * @param financialPlanContainsKeywordsPredicate Financial plan predicate. + * @param nameContainsKeywordsPredicate Name predicate. + * @param tagContainsKeywordsPredicate Tag predicate. + */ + public CombinedPredicate(FinancialPlanContainsKeywordsPredicate financialPlanContainsKeywordsPredicate, + NameContainsKeywordsPredicate nameContainsKeywordsPredicate, + TagContainsKeywordsPredicate tagContainsKeywordsPredicate) { + this.financialPlanContainsKeywordsPredicate = financialPlanContainsKeywordsPredicate; + this.nameContainsKeywordsPredicate = nameContainsKeywordsPredicate; + this.tagContainsKeywordsPredicate = tagContainsKeywordsPredicate; + } + + @Override + public boolean test(Person person) { + boolean result = false; + if (financialPlanContainsKeywordsPredicate != null) { + result = result || financialPlanContainsKeywordsPredicate.test(person); + } + if (nameContainsKeywordsPredicate != null) { + result = result || nameContainsKeywordsPredicate.test(person); + } + if (tagContainsKeywordsPredicate != null) { + result = result || tagContainsKeywordsPredicate.test(person); + } + return result; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + // instanceof handles nulls + if (!(other instanceof CombinedPredicate)) { + return false; + } + + CombinedPredicate otherNameContainsKeywordsPredicate = + (CombinedPredicate) other; + return Objects.equals(financialPlanContainsKeywordsPredicate, + otherNameContainsKeywordsPredicate.financialPlanContainsKeywordsPredicate) + && Objects.equals(nameContainsKeywordsPredicate, + otherNameContainsKeywordsPredicate.nameContainsKeywordsPredicate) + && Objects.equals(tagContainsKeywordsPredicate, + otherNameContainsKeywordsPredicate.tagContainsKeywordsPredicate); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .add("financialPlanContainsKeywordsPredicate", financialPlanContainsKeywordsPredicate) + .add("nameContainsKeywordsPredicate", nameContainsKeywordsPredicate) + .add("tagContainsKeywordsPredicate", tagContainsKeywordsPredicate) + .toString(); + } +} diff --git a/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java index aab323c91a4..50051ce9560 100644 --- a/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java +++ b/src/main/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicate.java @@ -1,49 +1,49 @@ -package seedu.address.model.person.predicates; - -import java.util.List; -import java.util.function.Predicate; -import java.util.stream.Stream; - -import seedu.address.commons.util.ToStringBuilder; -import seedu.address.model.financialplan.FinancialPlan; -import seedu.address.model.person.Person; - -/** - * Tests that at least one of a {@code Person}'s {@code FinancialPlan} matches any of the keywords given. - */ -public class FinancialPlanContainsKeywordsPredicate implements Predicate { - private final List keywords; - - public FinancialPlanContainsKeywordsPredicate(List keywords) { - this.keywords = keywords; - } - - @Override - public boolean test(Person person) { - Stream financialPlanStream = person.getFinancialPlans().stream(); - // We check for each financial plan if it contains a keyword as a substring - return financialPlanStream.anyMatch(financialPlan -> keywords.stream() - .anyMatch(keyword -> financialPlan.containsSubstring(keyword))); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - - // instanceof handles nulls - if (!(other instanceof FinancialPlanContainsKeywordsPredicate)) { - return false; - } - - FinancialPlanContainsKeywordsPredicate otherNameContainsKeywordsPredicate = - (FinancialPlanContainsKeywordsPredicate) other; - return keywords.equals(otherNameContainsKeywordsPredicate.keywords); - } - - @Override - public String toString() { - return new ToStringBuilder(this).add("keywords", keywords).toString(); - } -} +package seedu.address.model.person.predicates; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.financialplan.FinancialPlan; +import seedu.address.model.person.Person; + +/** + * Tests that at least one of a {@code Person}'s {@code FinancialPlan} matches any of the keywords given. + */ +public class FinancialPlanContainsKeywordsPredicate implements Predicate { + private final List keywords; + + public FinancialPlanContainsKeywordsPredicate(List keywords) { + this.keywords = keywords; + } + + @Override + public boolean test(Person person) { + Stream financialPlanStream = person.getFinancialPlans().stream(); + // We check for each financial plan if it contains a keyword as a substring + return financialPlanStream.anyMatch(financialPlan -> keywords.stream() + .anyMatch(keyword -> financialPlan.containsSubstring(keyword))); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + // instanceof handles nulls + if (!(other instanceof FinancialPlanContainsKeywordsPredicate)) { + return false; + } + + FinancialPlanContainsKeywordsPredicate otherNameContainsKeywordsPredicate = + (FinancialPlanContainsKeywordsPredicate) other; + return keywords.equals(otherNameContainsKeywordsPredicate.keywords); + } + + @Override + public String toString() { + return new ToStringBuilder(this).add("keywords", keywords).toString(); + } +} diff --git a/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java index e3ade77eacd..857fa9d93fa 100644 --- a/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java +++ b/src/main/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicate.java @@ -1,48 +1,48 @@ -package seedu.address.model.person.predicates; - -import java.util.List; -import java.util.function.Predicate; -import java.util.stream.Stream; - -import seedu.address.commons.util.ToStringBuilder; -import seedu.address.model.person.Person; -import seedu.address.model.tag.Tag; - -/** - * Tests that at least one of a {@code Person}'s {@code Tag} matches any of the keywords given. - */ -public class TagContainsKeywordsPredicate implements Predicate { - private final List keywords; - - public TagContainsKeywordsPredicate(List keywords) { - this.keywords = keywords; - } - - @Override - public boolean test(Person person) { - Stream tagStream = person.getTags().stream(); - // We check for each tag if it contains a keyword as a substring - return tagStream.anyMatch(tag -> keywords.stream() - .anyMatch(keyword -> tag.containsSubstring(keyword))); - } - - @Override - public boolean equals(Object other) { - if (other == this) { - return true; - } - - // instanceof handles nulls - if (!(other instanceof TagContainsKeywordsPredicate)) { - return false; - } - - TagContainsKeywordsPredicate otherNameContainsKeywordsPredicate = (TagContainsKeywordsPredicate) other; - return keywords.equals(otherNameContainsKeywordsPredicate.keywords); - } - - @Override - public String toString() { - return new ToStringBuilder(this).add("keywords", keywords).toString(); - } -} +package seedu.address.model.person.predicates; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import seedu.address.commons.util.ToStringBuilder; +import seedu.address.model.person.Person; +import seedu.address.model.tag.Tag; + +/** + * Tests that at least one of a {@code Person}'s {@code Tag} matches any of the keywords given. + */ +public class TagContainsKeywordsPredicate implements Predicate { + private final List keywords; + + public TagContainsKeywordsPredicate(List keywords) { + this.keywords = keywords; + } + + @Override + public boolean test(Person person) { + Stream tagStream = person.getTags().stream(); + // We check for each tag if it contains a keyword as a substring + return tagStream.anyMatch(tag -> keywords.stream() + .anyMatch(keyword -> tag.containsSubstring(keyword))); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + + // instanceof handles nulls + if (!(other instanceof TagContainsKeywordsPredicate)) { + return false; + } + + TagContainsKeywordsPredicate otherNameContainsKeywordsPredicate = (TagContainsKeywordsPredicate) other; + return keywords.equals(otherNameContainsKeywordsPredicate.keywords); + } + + @Override + public String toString() { + return new ToStringBuilder(this).add("keywords", keywords).toString(); + } +} diff --git a/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java index 0193952a8d5..51b3b07db31 100644 --- a/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java +++ b/src/test/java/seedu/address/model/person/predicates/CombinedPredicateTest.java @@ -1,110 +1,110 @@ -package seedu.address.model.person.predicates; - -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.util.Arrays; -import java.util.Collections; -import java.util.List; - -import org.junit.jupiter.api.Test; - -import seedu.address.testutil.PersonBuilder; - -public class CombinedPredicateTest { - @Test - public void equals() { - List firstPredicateKeywordList = Collections.singletonList("first"); - List secondPredicateKeywordList = Arrays.asList("first", "second"); - - CombinedPredicate firstPredicate = - new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList), - null, null); - CombinedPredicate secondPredicate = - new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(secondPredicateKeywordList), - null, null); - - // same object -> returns true - assertTrue(firstPredicate.equals(firstPredicate)); - - // same values -> returns true - CombinedPredicate firstPredicateCopy = - new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList), - null, null); - assertTrue(firstPredicate.equals(firstPredicateCopy)); - - // different types -> returns false - assertFalse(firstPredicate.equals(1)); - - // null -> returns false - assertFalse(firstPredicate.equals(null)); - - // different financial plan -> returns false - assertFalse(firstPredicate.equals(secondPredicate)); - } - - @Test - public void test_personContainsKeywords_returnsTrue() { - // One keyword - CombinedPredicate predicate = - new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), - null, null); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior").build())); - - // Multiple keywords for same person - predicate = new CombinedPredicate(null, - new NameContainsKeywordsPredicate(Arrays.asList("Alice", "Bob")), null); - assertTrue(predicate.test(new PersonBuilder().withName("Alice Bob").build())); - - // Multiple keywords in different fields - predicate = new CombinedPredicate( - new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), - new NameContainsKeywordsPredicate(Collections.singletonList("Alice")), null); - assertTrue(predicate.test(new PersonBuilder().withName("Alice").withFinancialPlans("Senior").build())); - - // Only one matching keyword - predicate = new CombinedPredicate( - new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), - new NameContainsKeywordsPredicate(Collections.singletonList("Alice")), null); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior", "Koopa").build())); - - // Mixed-case keywords - predicate = new CombinedPredicate( - new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("chILD")), - null, null); - assertTrue(predicate.test(new PersonBuilder().withName("Alice").withFinancialPlans("Child Premium").build())); - } - - @Test - public void test_personDoesNotContainKeywords_returnsFalse() { - // Zero keywords - CombinedPredicate predicate = - new CombinedPredicate(null, null, null); - assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").build())); - - // Non-matching keyword - predicate = new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior")), - null, null); - assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); - - // Keywords match phone, email and address, but does not match financial plans - predicate = new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate( - Arrays.asList("12345", "alice@email.com", "Main", "Street")), null, null); - assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").withPhone("12345") - .withEmail("alice@email.com").withAddress("Main Street").build())); - } - - @Test - public void toStringMethod() { - List keywords = List.of("keyword1", "keyword2"); - CombinedPredicate predicate = new CombinedPredicate(null, - new NameContainsKeywordsPredicate(keywords), null); - - String expected = CombinedPredicate.class.getCanonicalName() - + "{financialPlanContainsKeywordsPredicate=" + null + ", " - + "nameContainsKeywordsPredicate=" + predicate.nameContainsKeywordsPredicate.toString() + ", " - + "tagContainsKeywordsPredicate=" + null + "}"; - assertEquals(expected, predicate.toString()); - } -} +package seedu.address.model.person.predicates; + +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.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import seedu.address.testutil.PersonBuilder; + +public class CombinedPredicateTest { + @Test + public void equals() { + List firstPredicateKeywordList = Collections.singletonList("first"); + List secondPredicateKeywordList = Arrays.asList("first", "second"); + + CombinedPredicate firstPredicate = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList), + null, null); + CombinedPredicate secondPredicate = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(secondPredicateKeywordList), + null, null); + + // same object -> returns true + assertTrue(firstPredicate.equals(firstPredicate)); + + // same values -> returns true + CombinedPredicate firstPredicateCopy = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList), + null, null); + assertTrue(firstPredicate.equals(firstPredicateCopy)); + + // different types -> returns false + assertFalse(firstPredicate.equals(1)); + + // null -> returns false + assertFalse(firstPredicate.equals(null)); + + // different financial plan -> returns false + assertFalse(firstPredicate.equals(secondPredicate)); + } + + @Test + public void test_personContainsKeywords_returnsTrue() { + // One keyword + CombinedPredicate predicate = + new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), + null, null); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior").build())); + + // Multiple keywords for same person + predicate = new CombinedPredicate(null, + new NameContainsKeywordsPredicate(Arrays.asList("Alice", "Bob")), null); + assertTrue(predicate.test(new PersonBuilder().withName("Alice Bob").build())); + + // Multiple keywords in different fields + predicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), + new NameContainsKeywordsPredicate(Collections.singletonList("Alice")), null); + assertTrue(predicate.test(new PersonBuilder().withName("Alice").withFinancialPlans("Senior").build())); + + // Only one matching keyword + predicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")), + new NameContainsKeywordsPredicate(Collections.singletonList("Alice")), null); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior", "Koopa").build())); + + // Mixed-case keywords + predicate = new CombinedPredicate( + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("chILD")), + null, null); + assertTrue(predicate.test(new PersonBuilder().withName("Alice").withFinancialPlans("Child Premium").build())); + } + + @Test + public void test_personDoesNotContainKeywords_returnsFalse() { + // Zero keywords + CombinedPredicate predicate = + new CombinedPredicate(null, null, null); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").build())); + + // Non-matching keyword + predicate = new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior")), + null, null); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); + + // Keywords match phone, email and address, but does not match financial plans + predicate = new CombinedPredicate(new FinancialPlanContainsKeywordsPredicate( + Arrays.asList("12345", "alice@email.com", "Main", "Street")), null, null); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").withPhone("12345") + .withEmail("alice@email.com").withAddress("Main Street").build())); + } + + @Test + public void toStringMethod() { + List keywords = List.of("keyword1", "keyword2"); + CombinedPredicate predicate = new CombinedPredicate(null, + new NameContainsKeywordsPredicate(keywords), null); + + String expected = CombinedPredicate.class.getCanonicalName() + + "{financialPlanContainsKeywordsPredicate=" + null + ", " + + "nameContainsKeywordsPredicate=" + predicate.nameContainsKeywordsPredicate.toString() + ", " + + "tagContainsKeywordsPredicate=" + null + "}"; + assertEquals(expected, predicate.toString()); + } +} diff --git a/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java index cc6d6c5d519..a49fc1bcb17 100644 --- a/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java +++ b/src/test/java/seedu/address/model/person/predicates/FinancialPlanContainsKeywordsPredicateTest.java @@ -1,95 +1,95 @@ -package seedu.address.model.person.predicates; - -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.util.Arrays; -import java.util.Collections; -import java.util.List; - -import org.junit.jupiter.api.Test; - -import seedu.address.testutil.PersonBuilder; - -public class FinancialPlanContainsKeywordsPredicateTest { - @Test - public void equals() { - List firstPredicateKeywordList = Collections.singletonList("first"); - List secondPredicateKeywordList = Arrays.asList("first", "second"); - - FinancialPlanContainsKeywordsPredicate firstPredicate = - new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList); - FinancialPlanContainsKeywordsPredicate secondPredicate = - new FinancialPlanContainsKeywordsPredicate(secondPredicateKeywordList); - - // same object -> returns true - assertTrue(firstPredicate.equals(firstPredicate)); - - // same values -> returns true - FinancialPlanContainsKeywordsPredicate firstPredicateCopy = - new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList); - assertTrue(firstPredicate.equals(firstPredicateCopy)); - - // different types -> returns false - assertFalse(firstPredicate.equals(1)); - - // null -> returns false - assertFalse(firstPredicate.equals(null)); - - // different financial plan -> returns false - assertFalse(firstPredicate.equals(secondPredicate)); - } - - @Test - public void test_financialPlansContainKeywords_returnsTrue() { - // One keyword - FinancialPlanContainsKeywordsPredicate predicate = - new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior").build())); - - // Multiple keywords in same financial plan - predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior Premium").build())); - - // Multiple keywords in different financial plans - predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior", "Premium").build())); - - // Only one matching keyword - predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Child", "Premium")); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Child", "Koopa").build())); - - // Mixed-case keywords - predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("cHIld", "pREmium")); - assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); - } - - @Test - public void test_financialPlansDoNotContainKeywords_returnsFalse() { - // Zero keywords - FinancialPlanContainsKeywordsPredicate predicate = - new FinancialPlanContainsKeywordsPredicate(Collections.emptyList()); - assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").build())); - - // Non-matching keyword - predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior")); - assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); - - // Keywords match phone, email and address, but does not match financial plans - predicate = new FinancialPlanContainsKeywordsPredicate( - Arrays.asList("12345", "alice@email.com", "Main", "Street")); - assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").withPhone("12345") - .withEmail("alice@email.com").withAddress("Main Street").build())); - } - - @Test - public void toStringMethod() { - List keywords = List.of("keyword1", "keyword2"); - FinancialPlanContainsKeywordsPredicate predicate = new FinancialPlanContainsKeywordsPredicate(keywords); - - String expected = FinancialPlanContainsKeywordsPredicate.class.getCanonicalName() - + "{keywords=" + keywords + "}"; - assertEquals(expected, predicate.toString()); - } -} +package seedu.address.model.person.predicates; + +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.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import seedu.address.testutil.PersonBuilder; + +public class FinancialPlanContainsKeywordsPredicateTest { + @Test + public void equals() { + List firstPredicateKeywordList = Collections.singletonList("first"); + List secondPredicateKeywordList = Arrays.asList("first", "second"); + + FinancialPlanContainsKeywordsPredicate firstPredicate = + new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList); + FinancialPlanContainsKeywordsPredicate secondPredicate = + new FinancialPlanContainsKeywordsPredicate(secondPredicateKeywordList); + + // same object -> returns true + assertTrue(firstPredicate.equals(firstPredicate)); + + // same values -> returns true + FinancialPlanContainsKeywordsPredicate firstPredicateCopy = + new FinancialPlanContainsKeywordsPredicate(firstPredicateKeywordList); + assertTrue(firstPredicate.equals(firstPredicateCopy)); + + // different types -> returns false + assertFalse(firstPredicate.equals(1)); + + // null -> returns false + assertFalse(firstPredicate.equals(null)); + + // different financial plan -> returns false + assertFalse(firstPredicate.equals(secondPredicate)); + } + + @Test + public void test_financialPlansContainKeywords_returnsTrue() { + // One keyword + FinancialPlanContainsKeywordsPredicate predicate = + new FinancialPlanContainsKeywordsPredicate(Collections.singletonList("Senior")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior").build())); + + // Multiple keywords in same financial plan + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior Premium").build())); + + // Multiple keywords in different financial plans + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Senior", "Premium").build())); + + // Only one matching keyword + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Child", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Child", "Koopa").build())); + + // Mixed-case keywords + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("cHIld", "pREmium")); + assertTrue(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); + } + + @Test + public void test_financialPlansDoNotContainKeywords_returnsFalse() { + // Zero keywords + FinancialPlanContainsKeywordsPredicate predicate = + new FinancialPlanContainsKeywordsPredicate(Collections.emptyList()); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").build())); + + // Non-matching keyword + predicate = new FinancialPlanContainsKeywordsPredicate(Arrays.asList("Senior")); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child Premium").build())); + + // Keywords match phone, email and address, but does not match financial plans + predicate = new FinancialPlanContainsKeywordsPredicate( + Arrays.asList("12345", "alice@email.com", "Main", "Street")); + assertFalse(predicate.test(new PersonBuilder().withFinancialPlans("Child").withPhone("12345") + .withEmail("alice@email.com").withAddress("Main Street").build())); + } + + @Test + public void toStringMethod() { + List keywords = List.of("keyword1", "keyword2"); + FinancialPlanContainsKeywordsPredicate predicate = new FinancialPlanContainsKeywordsPredicate(keywords); + + String expected = FinancialPlanContainsKeywordsPredicate.class.getCanonicalName() + + "{keywords=" + keywords + "}"; + assertEquals(expected, predicate.toString()); + } +} diff --git a/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java b/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java index e1640b9d862..f7610e70ec6 100644 --- a/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java +++ b/src/test/java/seedu/address/model/person/predicates/TagContainsKeywordsPredicateTest.java @@ -1,88 +1,88 @@ -package seedu.address.model.person.predicates; - -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.util.Arrays; -import java.util.Collections; -import java.util.List; - -import org.junit.jupiter.api.Test; - -import seedu.address.testutil.PersonBuilder; - -public class TagContainsKeywordsPredicateTest { - @Test - public void equals() { - List firstPredicateKeywordList = Collections.singletonList("first"); - List secondPredicateKeywordList = Arrays.asList("first", "second"); - - TagContainsKeywordsPredicate firstPredicate = new TagContainsKeywordsPredicate(firstPredicateKeywordList); - TagContainsKeywordsPredicate secondPredicate = new TagContainsKeywordsPredicate(secondPredicateKeywordList); - - // same object -> returns true - assertTrue(firstPredicate.equals(firstPredicate)); - - // same values -> returns true - TagContainsKeywordsPredicate firstPredicateCopy = new TagContainsKeywordsPredicate(firstPredicateKeywordList); - assertTrue(firstPredicate.equals(firstPredicateCopy)); - - // different types -> returns false - assertFalse(firstPredicate.equals(1)); - - // null -> returns false - assertFalse(firstPredicate.equals(null)); - - // different tag -> returns false - assertFalse(firstPredicate.equals(secondPredicate)); - } - - @Test - public void test_tagsContainKeywords_returnsTrue() { - // One keyword - TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(Collections.singletonList("Senior")); - assertTrue(predicate.test(new PersonBuilder().withTags("Senior").build())); - - // Multiple keywords in same tag - predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); - assertTrue(predicate.test(new PersonBuilder().withTags("SeniorPremium").build())); - - // Multiple keywords in different tags - predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); - assertTrue(predicate.test(new PersonBuilder().withTags("Senior", "Premium").build())); - - // Only one matching keyword - predicate = new TagContainsKeywordsPredicate(Arrays.asList("Child", "Premium")); - assertTrue(predicate.test(new PersonBuilder().withTags("Child", "Koopa").build())); - - // Mixed-case keywords - predicate = new TagContainsKeywordsPredicate(Arrays.asList("cHIld", "pREmium")); - assertTrue(predicate.test(new PersonBuilder().withTags("ChildPremium").build())); - } - - @Test - public void test_tagsDoNotContainKeywords_returnsFalse() { - // Zero keywords - TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(Collections.emptyList()); - assertFalse(predicate.test(new PersonBuilder().withTags("Child").build())); - - // Non-matching keyword - predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior")); - assertFalse(predicate.test(new PersonBuilder().withTags("ChildPremium").build())); - - // Keywords match phone, email and address, but does not match tags - predicate = new TagContainsKeywordsPredicate(Arrays.asList("12345", "alice@email.com", "Main", "Street")); - assertFalse(predicate.test(new PersonBuilder().withTags("Child").withPhone("12345") - .withEmail("alice@email.com").withAddress("Main Street").build())); - } - - @Test - public void toStringMethod() { - List keywords = List.of("keyword1", "keyword2"); - TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(keywords); - - String expected = TagContainsKeywordsPredicate.class.getCanonicalName() + "{keywords=" + keywords + "}"; - assertEquals(expected, predicate.toString()); - } -} +package seedu.address.model.person.predicates; + +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.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import seedu.address.testutil.PersonBuilder; + +public class TagContainsKeywordsPredicateTest { + @Test + public void equals() { + List firstPredicateKeywordList = Collections.singletonList("first"); + List secondPredicateKeywordList = Arrays.asList("first", "second"); + + TagContainsKeywordsPredicate firstPredicate = new TagContainsKeywordsPredicate(firstPredicateKeywordList); + TagContainsKeywordsPredicate secondPredicate = new TagContainsKeywordsPredicate(secondPredicateKeywordList); + + // same object -> returns true + assertTrue(firstPredicate.equals(firstPredicate)); + + // same values -> returns true + TagContainsKeywordsPredicate firstPredicateCopy = new TagContainsKeywordsPredicate(firstPredicateKeywordList); + assertTrue(firstPredicate.equals(firstPredicateCopy)); + + // different types -> returns false + assertFalse(firstPredicate.equals(1)); + + // null -> returns false + assertFalse(firstPredicate.equals(null)); + + // different tag -> returns false + assertFalse(firstPredicate.equals(secondPredicate)); + } + + @Test + public void test_tagsContainKeywords_returnsTrue() { + // One keyword + TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(Collections.singletonList("Senior")); + assertTrue(predicate.test(new PersonBuilder().withTags("Senior").build())); + + // Multiple keywords in same tag + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withTags("SeniorPremium").build())); + + // Multiple keywords in different tags + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withTags("Senior", "Premium").build())); + + // Only one matching keyword + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Child", "Premium")); + assertTrue(predicate.test(new PersonBuilder().withTags("Child", "Koopa").build())); + + // Mixed-case keywords + predicate = new TagContainsKeywordsPredicate(Arrays.asList("cHIld", "pREmium")); + assertTrue(predicate.test(new PersonBuilder().withTags("ChildPremium").build())); + } + + @Test + public void test_tagsDoNotContainKeywords_returnsFalse() { + // Zero keywords + TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(Collections.emptyList()); + assertFalse(predicate.test(new PersonBuilder().withTags("Child").build())); + + // Non-matching keyword + predicate = new TagContainsKeywordsPredicate(Arrays.asList("Senior")); + assertFalse(predicate.test(new PersonBuilder().withTags("ChildPremium").build())); + + // Keywords match phone, email and address, but does not match tags + predicate = new TagContainsKeywordsPredicate(Arrays.asList("12345", "alice@email.com", "Main", "Street")); + assertFalse(predicate.test(new PersonBuilder().withTags("Child").withPhone("12345") + .withEmail("alice@email.com").withAddress("Main Street").build())); + } + + @Test + public void toStringMethod() { + List keywords = List.of("keyword1", "keyword2"); + TagContainsKeywordsPredicate predicate = new TagContainsKeywordsPredicate(keywords); + + String expected = TagContainsKeywordsPredicate.class.getCanonicalName() + "{keywords=" + keywords + "}"; + assertEquals(expected, predicate.toString()); + } +} From 61ab25d6aaf6f25d01df0b63591750a6cae6afbe Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 13:30:01 +0800 Subject: [PATCH 04/11] Re-add removed test --- .../address/logic/commands/FindCommandTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/seedu/address/logic/commands/FindCommandTest.java b/src/test/java/seedu/address/logic/commands/FindCommandTest.java index 3e7d98fb21a..a9b4652f2bd 100644 --- a/src/test/java/seedu/address/logic/commands/FindCommandTest.java +++ b/src/test/java/seedu/address/logic/commands/FindCommandTest.java @@ -12,6 +12,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.function.Predicate; import org.junit.jupiter.api.Test; @@ -20,6 +21,8 @@ import seedu.address.model.ModelManager; import seedu.address.model.UserPrefs; import seedu.address.model.person.Person; +import seedu.address.model.person.predicates.CombinedPredicate; +import seedu.address.model.person.predicates.FinancialPlanContainsKeywordsPredicate; import seedu.address.model.person.predicates.NameContainsKeywordsPredicate; import seedu.address.model.person.predicates.TagContainsKeywordsPredicate; @@ -66,6 +69,20 @@ public void equals() { assertFalse(findFirstCommand.equals(findSecondCommand)); } + @Test + public void execute_zeroKeywords_noPersonFound() { + String expectedMessage = String.format(MESSAGE_PERSONS_LISTED_OVERVIEW, 0); + NameContainsKeywordsPredicate namePredicate = new NameContainsKeywordsPredicate(List.of()); + FinancialPlanContainsKeywordsPredicate financialPlanPredicate = + new FinancialPlanContainsKeywordsPredicate(List.of()); + TagContainsKeywordsPredicate tagPredicate = new TagContainsKeywordsPredicate(List.of()); + CombinedPredicate combinedPredicate = new CombinedPredicate(financialPlanPredicate, + namePredicate, tagPredicate); + FindCommand command = new FindCommand(combinedPredicate); + expectedModel.updateFilteredPersonList(combinedPredicate); + assertCommandSuccess(command, model, expectedMessage, expectedModel); + assertEquals(Collections.emptyList(), model.getFilteredPersonList()); + } @Test public void execute_multipleKeywords_multiplePersonsFound() { String expectedMessage = String.format(MESSAGE_PERSONS_LISTED_OVERVIEW, 3); From d5dd1493b6e0be49a9047c0ee5b042a8aa3c6d09 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 14:03:53 +0800 Subject: [PATCH 05/11] Update User Guide to contain new find command information --- docs/UserGuide.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 52d3e5e7b95..b02cd0b7802 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -150,20 +150,20 @@ Next-of-Kin Phone: 82020202 ### Locating persons by name: `find` -Finds persons whose names contain any of the given keywords. +Finds persons whose names, tags or financial plans contain any of the specified keywords. -Format: `find KEYWORD [MORE_KEYWORDS]` +Format: `find [n/NAME]…​ [fp/FINANCIAL_PLAN]…​ [t/TAG]…​` +* At least one of the optional fields must be provided. * The search is case-insensitive. e.g `hans` will match `Hans` -* The order of the keywords does not matter. e.g. `Hans Bo` will match `Bo Hans` -* Only the name is searched. -* Only full words will be matched e.g. `Han` will not match `Hans` +* For names, only full words will be matched e.g. `Han` will not match `Hans` +* For financial plans and tags, any substring will be matched e.g. `Senior` will match `SuperSenior` * Persons matching at least one keyword will be returned (i.e. `OR` search). - e.g. `Hans Bo` will return `Hans Gruber`, `Bo Yang` + e.g. `n/Hans n/Bo` will return `Hans Gruber`, `Bo Yang` Examples: -* `find John` returns `john` and `John Doe` -* `find alex david` returns `Alex Yeoh`, `David Li`
+* `find n/John` returns `john` and `John Doe` +* `find n/alex n/david` returns `Alex Yeoh`, `David Li`
![result for 'find alex david'](images/findAlexDavidResult.png) ### Gathering clients' emails by financial plan: `gather` @@ -262,7 +262,7 @@ _Details coming soon ..._ | **Clear** | `clear` | | **Delete** | `delete INDEX`
e.g., `delete 3` | | **Edit** | `edit ENTRY_INDEX [n/NAME] [p/PHONE_NUMBER] [e/EMAIL] [a/ADDRESS] [nk/NEXT_KIN] [nkp/NEXT_KIN_PHONE] [t/TAG]…​`
e.g.,`edit 1 n/john doe a/23 woodlands ave 123` | -| **Find** | `find KEYWORD [MORE_KEYWORDS]`
e.g., `find James Jake` | +| **Find** | `find [n/NAME]…​ [fp/FINANCIAL_PLAN]…​ [t/TAG]…​`
e.g., `find n/James n/Jake` | | **Gather** | `gather FINANCIAL_PLAN_NAME`
e.g., `gather Basic Insurance Plan` | | **List** | `list` | | **Help** | `help` | From 0419ea606e9a169c1350f6461dcab082c9accf62 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 15:40:43 +0800 Subject: [PATCH 06/11] Update Developer Guide with implementation details for enhanced Find --- docs/DeveloperGuide.md | 30 ++++++++++++++++++ docs/diagrams/FindCommandDiagram.puml | 15 +++++++++ docs/images/CombinedPredicate.png | Bin 0 -> 52618 bytes .../address/logic/commands/FindCommand.java | 13 ++++---- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 docs/diagrams/FindCommandDiagram.puml create mode 100644 docs/images/CombinedPredicate.png diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index ac6759c6cac..48ebdcc608d 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -156,6 +156,36 @@ Classes used by multiple components are in the `seedu.addressbook.commons` packa This section describes some noteworthy details on how certain features are implemented. +### Expanded Find feature + +The enhanced find mechanism is facilitated by the `CombinedPredicate` and utilises the existing FindCommand structure. +It extends `Predicate` and is composed of up to one of a `NameContainsKeywordsPredicate`, `FinancialPlanContainsKeywordsPredicate` +and a `TagContainsKeywordsPredicate` each. Here's a partial class diagram of the `CombinedPredicate`. +![CombinedPredicate](images/CombinedPredicate.png) + +The Find command format also changes to resemble a format more similar to the `add` and `edit` commands, to allow for +searching for keywords in multiple fields at the same time. + +#### Design Considerations: + +**Aspect: How to implement find for multiple fields** +* **Alternative 1 (current choice):** Use one unified command and format. + * Pros: Easy to implement (argument multimap is available), allows for more flexible usage. + * Cons: May get cluttered when there are many terms. + +* **Alternative 2:** Take an argument to decide which field to find by. + * Pros: More user-friendly and natural since there is no need to use prefixes. + * Cons: Less flexible, slightly more difficult to implement. + +**Aspect: How to implement `CombinedPredicate`** +* **Alternative 1 (current choice):** Compose it with the 3 component predicates. + * Pros: Easier to modify and test. + * Cons: Less flexible when trying to combine multiple predicates (that may be of the same type). + +* **Alternative 2:** Use a `Predicate` and use the `or()` method to chain predicates. + * Pros: More flexible in usage. + * Cons: More difficult to modify and test. + ### \[Proposed\] Undo/redo feature #### Proposed Implementation diff --git a/docs/diagrams/FindCommandDiagram.puml b/docs/diagrams/FindCommandDiagram.puml new file mode 100644 index 00000000000..642c5bc075d --- /dev/null +++ b/docs/diagrams/FindCommandDiagram.puml @@ -0,0 +1,15 @@ +@startuml +!include style.puml +skinparam arrowThickness 1.1 +skinparam arrowColor LOGIC_COLOR_T4 +skinparam classBackgroundColor LOGIC_COLOR + +CombinedPredicate .up.|> "Predicate" +CombinedPredicate --> "0...1" NameContainsKeywordsPredicate +CombinedPredicate --> "0...1" FinancialPlanContainsKeywordsPredicate +CombinedPredicate --> "0...1" TagContainsKeywordsPredicate + +FindCommandParser ..> CombinedPredicate : creates > +FindCommandParser ..> FindCommand : creates > +FindCommand-->CombinedPredicate +@enduml \ No newline at end of file diff --git a/docs/images/CombinedPredicate.png b/docs/images/CombinedPredicate.png new file mode 100644 index 0000000000000000000000000000000000000000..f0fff10d5f6cb1ad0aeebda7567281fd1651a3f9 GIT binary patch literal 52618 zcmb5Wc{rQh*ESqlZErf4>ZG*2t1U$*L#d%uwWd-M#MG7+iI`&uLA9!_8akk*D6J`x z#1um!l-69c2qK0mArUbLF??70d*0`HzvFnmKfe5t9CBUR*WPQdz4kiKb#AZBObzyk z9uozDKzr`qxpf}|+SUjH2}SJM0eo{~FY6Qt6b`z3>!xL(!!%=Oh{WTnt+}n`A>NEb zSp|LuPx7dsk^E_|U%&ifz5nI`^ZeXCvGXra%gMZa*%j=0`t6X$(+*vkZ#RXfC=2{_ z?t6{VOLzB+H(p5Rv6m`kTqb^fx_a)@LJ0H1!j@vywCGo}^WvhBx*39xv&k)6hc%{1MWVWvwN^rxe2E3VM?#=jLBd45`E8|d_XH#g!EC&gLPzNFH0^__Bf%D?Pmn{ysJN1&G$Gh^yy$?*4_71l}ERd6Oso}P#QCyF2wmQp zq+|;#wc>dbCW(I0(u5Np3|A#}m6|fnyq5eH^GI9SGVm0gF`2);MGLJCZ#j5KX?TJlcG}Ci)YlUXK>aRk}7P?r-%+==o}>1!D66n z*jdt{eEs8##XINKWz}N?1`BI8^4m+0>^?oMA5kXV3#96jQYJ zpah?TK6z3>cceJneYJ_}PqHV!<_ra^QvacL+If^ej6u*L&b|}V0oslM447?I8^t0Gcyz5mfk1Cf~#Fj@+3k(-6uLnMRhmg4%^lV%cRSOebnkY3$OLoc_A6lSnXq0nz4hPKg7fBa(R{Nvvzf+j(>+1E; zhOjrV*vGBbqa$OMj#jSRrAlP7ioLC;j;Wzs9y-jSGuXA?Cp9ZOabTGfIkP%6hQf_F zO-dHN@fD?=5yv<59;7tGJQC(Jh8ryFaw?6sR$E@GU zb|IRE&fmoxr>h6~ki^Jr8j~I_)Z{;vB@a)}3I7V2>@Vh#Wy)O5OEouFAki@jYX8b8 z*(N$kNbHl!30&>f){%z_SC*avf9WnA76 zxpQr?UuqXKs5Hp;shHi?YP%tv3VYU?OdQq>Tb(}<{QDJ7u?IdpL-u@c^6A}yU5RYO zpGM8Yl2#Akv|4=ij2hhH0NcjAf_vZVy#`=f1?M z{0uM$NJ?5&AlDV*qU1$~MJq*nMcp^}=PLH6q}o4hYC8LrSJsOl=tmx)CnzY4^(< zMI%Jt4y4sP!!^+!qVbWD2{USJ-I~QIzA&BO)lgg&K1~e420nG<8a3D`G!EQDcBW}- zw-go04F-2y{(R{nf!&7ZWjf_>ZY=ev$SdIdC54w~6nt;;p6z?bnCsL!FH^l0`i@WO z@R2&hiFU9(M8Vm8Vh??F&^r~_^zKC25ScsUpZ;}Ouf=bA=@<$jCC`o625dUVb_#Z$ zw|gby;lhuy;XqK+44%OtBJ-7G&wXfAk&Kc#x7^c%753s-0n)10rRTU|yPgK85VjDxUkMX^L( z>`=;bp0~2!B9AzG@B{5ZB43sYzUs46E0k+2ptS!?%-|ub;`(N z2iKMv_QzS2;hq0)Z~mM|^=OFIi-tle-k3eW{^DupdnXqh;7p8XA;nRPCx}OO#HoVs z-Z_uqJmLVhOgrhO(~I(~PMu7nl_E^;EWIwx)4}e?dgX-YLmm*_gW2a-n2O3O7hyE#~_5zJ>W>lzk zhN}suquNoLlq%h_O#+?{du3(iqa*`_(iIc|H$rg2^gbcV4x`xWy!DZ}78VRwknd9` zAQL;yuqj%v&fgYniIsmY_!~LkdO`AZ~BN8InBYdJ6qV6C|9<88Hb tiY-@$rqucA506N; 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%0AwvRqV#q<4Ibp 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 Ryao^W)7LiA`l?|I|6l7-DBS=6 literal 0 HcmV?d00001 diff --git a/src/main/java/seedu/address/logic/commands/FindCommand.java b/src/main/java/seedu/address/logic/commands/FindCommand.java index 7bff17114c7..7ea5c24638d 100644 --- a/src/main/java/seedu/address/logic/commands/FindCommand.java +++ b/src/main/java/seedu/address/logic/commands/FindCommand.java @@ -22,13 +22,14 @@ public class FindCommand extends Command { public static final String MESSAGE_USAGE = COMMAND_WORD + ": Finds all persons whose names, tags or financial" + " plans contain any of " - + "the specified keywords (case-insensitive) and displays them as a list with index numbers.\n" + + "the specified keywords (case-insensitive) and displays them as a list with index numbers. " + + "At least one argument must be given.\n" + "Parameters: " - + "[" + PREFIX_NAME + "NAME] and/or " - + "[" + PREFIX_FINANCIAL_PLAN + "FINANCIAL_PLAN] and/or " - + "[" + PREFIX_TAG + "TAG]\n" - + "Example: " + COMMAND_WORD + " " + PREFIX_FINANCIAL_PLAN + "Financial Plan A" - + " " + PREFIX_TAG + "TagA"; + + "[" + PREFIX_NAME + "NAME]... " + + "[" + PREFIX_FINANCIAL_PLAN + "FINANCIAL_PLAN]... " + + "[" + PREFIX_TAG + "TAG]...\n" + + "Example: " + COMMAND_WORD + " " + PREFIX_FINANCIAL_PLAN + "Financial Plan A " + + PREFIX_TAG + "TagA"; private final Predicate predicate; From 36cbbdf92f51576823f62cc5534461bf2424b244 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 15:42:46 +0800 Subject: [PATCH 07/11] Update Developer Guide for more details on enhanced Find --- docs/DeveloperGuide.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 48ebdcc608d..5e445d5ad83 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -163,6 +163,14 @@ It extends `Predicate` and is composed of up to one of a `NameContainsKe and a `TagContainsKeywordsPredicate` each. Here's a partial class diagram of the `CombinedPredicate`. ![CombinedPredicate](images/CombinedPredicate.png) +The `NameContainsKeywordsPredicate`, `FinancialPlanContainsKeywordsPredicate` and +`TagContainsKeywordsPredicate` check a Person if the respective field contains +any of the keywords supplied to the predicate. Note that only the `NameContainsKeywordsPredicate` +checks for whole words, because it is rare to search for people by substrings, while `FinancialPlanContainsKeywordsPredicate` +and `TagContainsKeywordsPredicate` allow matching for substrings because there are certain cases where it is logical to search + for substrings e.g. `Plan A` and `Plan A Premium` are related, so they can show up in the same +search. + The Find command format also changes to resemble a format more similar to the `add` and `edit` commands, to allow for searching for keywords in multiple fields at the same time. From 05c102b61e6fb8d8baa179b762938997bde3825c Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 15:58:35 +0800 Subject: [PATCH 08/11] Fix style --- .../{FindCommandDiagram.puml => CombinedPredicateDiagram.puml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/diagrams/{FindCommandDiagram.puml => CombinedPredicateDiagram.puml} (100%) diff --git a/docs/diagrams/FindCommandDiagram.puml b/docs/diagrams/CombinedPredicateDiagram.puml similarity index 100% rename from docs/diagrams/FindCommandDiagram.puml rename to docs/diagrams/CombinedPredicateDiagram.puml From bde04400870e763c612ba7b890fe1e8acea0d4e5 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:04:38 +0800 Subject: [PATCH 09/11] Fix bugs --- docs/DeveloperGuide.md | 5 +-- docs/diagrams/CombinedPredicateDiagram.puml | 2 +- .../address/commons/util/StringUtil.java | 44 +++++++++++++++++++ .../logic/parser/FindCommandParser.java | 6 +-- .../NameContainsKeywordsPredicate.java | 2 +- .../address/commons/util/StringUtilTest.java | 40 +++++++++++++++++ 6 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 5e445d5ad83..a661dd21f48 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -167,9 +167,8 @@ The `NameContainsKeywordsPredicate`, `FinancialPlanContainsKeywordsPredicate` an `TagContainsKeywordsPredicate` check a Person if the respective field contains any of the keywords supplied to the predicate. Note that only the `NameContainsKeywordsPredicate` checks for whole words, because it is rare to search for people by substrings, while `FinancialPlanContainsKeywordsPredicate` -and `TagContainsKeywordsPredicate` allow matching for substrings because there are certain cases where it is logical to search - for substrings e.g. `Plan A` and `Plan A Premium` are related, so they can show up in the same -search. +and `TagContainsKeywordsPredicate` allow matching for substrings because there are certain cases where it is logical to search for +substrings e.g. `Plan A` and `Plan A Premium` are related, so they can show up in the same search. The Find command format also changes to resemble a format more similar to the `add` and `edit` commands, to allow for searching for keywords in multiple fields at the same time. diff --git a/docs/diagrams/CombinedPredicateDiagram.puml b/docs/diagrams/CombinedPredicateDiagram.puml index 642c5bc075d..e8af1067219 100644 --- a/docs/diagrams/CombinedPredicateDiagram.puml +++ b/docs/diagrams/CombinedPredicateDiagram.puml @@ -12,4 +12,4 @@ CombinedPredicate --> "0...1" TagContainsKeywordsPredicate FindCommandParser ..> CombinedPredicate : creates > FindCommandParser ..> FindCommand : creates > FindCommand-->CombinedPredicate -@enduml \ No newline at end of file +@enduml diff --git a/src/main/java/seedu/address/commons/util/StringUtil.java b/src/main/java/seedu/address/commons/util/StringUtil.java index 61cc8c9a1cb..9932096f07d 100644 --- a/src/main/java/seedu/address/commons/util/StringUtil.java +++ b/src/main/java/seedu/address/commons/util/StringUtil.java @@ -6,6 +6,9 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; /** * Helper functions for handling strings. @@ -38,6 +41,47 @@ public static boolean containsWordIgnoreCase(String sentence, String word) { .anyMatch(preppedWord::equalsIgnoreCase); } + /** + * Returns true if the {@code sentence} contains the {@code words}. + * Ignores case, but a full phrase match is required. + *
examples:
+     *       containsWordsIgnoreCase("ABc def", "abc def") == true
+     *       containsWordsIgnoreCase("ABc def", "DEF") == true
+     *       containsWordsIgnoreCase("ABc def", "ABc de") == false //not a full word match
+     *       
+ * @param sentence cannot be null + * @param words cannot be null, cannot be empty + */ + public static boolean containsWordsIgnoreCase(String sentence, String words) { + requireNonNull(sentence); + requireNonNull(words); + + String trimmedWords = words.trim(); + checkArgument(!trimmedWords.isEmpty(), "Word parameter cannot be empty"); + List preppedWords = prepareWords(trimmedWords); + + String trimmedSentence = sentence.trim(); + if (trimmedSentence.isEmpty()) { + return false; + } + List preppedSentence = prepareWords(sentence); + + return Collections.indexOfSubList(preppedSentence, preppedWords) != -1; + } + + /** + * Prepares the given words to be compared in the containsWordsIgnoreCase() method. + * + * @param words cannot be null + * @return the prepared list of strings + */ + private static List prepareWords(String words) { + requireNonNull(words); + return Arrays.stream(words.split("\\s+")) + .map(word -> word.toLowerCase()) + .collect(Collectors.toList()); + } + /** * Returns a detailed message of the t, including the stack trace. */ diff --git a/src/main/java/seedu/address/logic/parser/FindCommandParser.java b/src/main/java/seedu/address/logic/parser/FindCommandParser.java index 05e800a0703..2ed3f3f3268 100644 --- a/src/main/java/seedu/address/logic/parser/FindCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/FindCommandParser.java @@ -29,12 +29,12 @@ public class FindCommandParser implements Parser { */ public FindCommand parse(String args) throws ParseException { String trimmedArgs = args.trim(); - if (trimmedArgs.isEmpty()) { + ArgumentMultimap argMultimap = ArgumentTokenizer.tokenize(args, PREFIX_NAME, + PREFIX_FINANCIAL_PLAN, PREFIX_TAG); + if (trimmedArgs.isEmpty() || argMultimap.getPreamble() != "") { throw new ParseException( String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); } - ArgumentMultimap argMultimap = ArgumentTokenizer.tokenize(args, PREFIX_NAME, - PREFIX_FINANCIAL_PLAN, PREFIX_TAG); List nameKeywords = argMultimap.getAllValues(PREFIX_NAME); nameKeywords.replaceAll(name -> name.trim()); diff --git a/src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java b/src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java index 0a040a7f3c7..152c722dfdd 100644 --- a/src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java +++ b/src/main/java/seedu/address/model/person/predicates/NameContainsKeywordsPredicate.java @@ -20,7 +20,7 @@ public NameContainsKeywordsPredicate(List keywords) { @Override public boolean test(Person person) { return keywords.stream() - .anyMatch(keyword -> StringUtil.containsWordIgnoreCase(person.getName().fullName, keyword)); + .anyMatch(keyword -> StringUtil.containsWordsIgnoreCase(person.getName().fullName, keyword)); } @Override diff --git a/src/test/java/seedu/address/commons/util/StringUtilTest.java b/src/test/java/seedu/address/commons/util/StringUtilTest.java index c56d407bf3f..d693eace727 100644 --- a/src/test/java/seedu/address/commons/util/StringUtilTest.java +++ b/src/test/java/seedu/address/commons/util/StringUtilTest.java @@ -123,6 +123,46 @@ public void containsWordIgnoreCase_validInputs_correctResult() { assertTrue(StringUtil.containsWordIgnoreCase("AAA bBb ccc bbb", "bbB")); } + + //---------------- Tests for containsWordsIgnoreCase ------------------------- + @Test + public void containsWordsIgnoreCase_nullWord_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> StringUtil.containsWordsIgnoreCase("typical sentence", null)); + } + + @Test + public void containsWordsIgnoreCase_emptyWord_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, "Word parameter cannot be empty", () + -> StringUtil.containsWordsIgnoreCase("typical sentence", " ")); + } + + @Test + public void containsWordsIgnoreCase_nullSentence_throwsNullPointerException() { + assertThrows(NullPointerException.class, () -> StringUtil.containsWordsIgnoreCase(null, "abc")); + } + + @Test + public void containsWordsIgnoreCase_validInputs_correctResult() { + + // Empty sentence + assertFalse(StringUtil.containsWordsIgnoreCase("", "abc")); // Boundary case + assertFalse(StringUtil.containsWordsIgnoreCase(" ", "123")); + + // Matches a partial word only + assertFalse(StringUtil.containsWordsIgnoreCase("aaa bbb ccc", "bbb c")); // Sentence word bigger than query word + assertFalse(StringUtil.containsWordsIgnoreCase("aaa bbb ccc", "bbbb")); // Query word bigger than sentence word + + // Matches word in the sentence, different upper/lower case letters + assertTrue(StringUtil.containsWordsIgnoreCase("aaa bBb ccc", "Bbb")); // First word (boundary case) + assertTrue(StringUtil.containsWordsIgnoreCase("aaa bBb ccc@1", "CCc@1")); // Last word (boundary case) + assertTrue(StringUtil.containsWordsIgnoreCase(" AAA bBb ccc ", "aaa bbb")); // Sentence has extra spaces + assertTrue(StringUtil.containsWordsIgnoreCase("Aaa", "aaa")); // Only one word in sentence (boundary case) + assertTrue(StringUtil.containsWordsIgnoreCase("aaa bbb ccc", " bbb ccc ")); // Leading/trailing spaces + + // Matches multiple words in sentence + assertTrue(StringUtil.containsWordsIgnoreCase("AAA bBb ccc bbb ccc", "bbB ccc")); + } + //---------------- Tests for getDetails -------------------------------------- /* From 172e4cd8ec2a64c0d17757bc9cf402166af4e241 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:16:08 +0800 Subject: [PATCH 10/11] Fix more bugs --- src/main/java/seedu/address/logic/parser/FindCommandParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/seedu/address/logic/parser/FindCommandParser.java b/src/main/java/seedu/address/logic/parser/FindCommandParser.java index 2ed3f3f3268..8bffe6c32e9 100644 --- a/src/main/java/seedu/address/logic/parser/FindCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/FindCommandParser.java @@ -31,7 +31,7 @@ public FindCommand parse(String args) throws ParseException { String trimmedArgs = args.trim(); ArgumentMultimap argMultimap = ArgumentTokenizer.tokenize(args, PREFIX_NAME, PREFIX_FINANCIAL_PLAN, PREFIX_TAG); - if (trimmedArgs.isEmpty() || argMultimap.getPreamble() != "") { + if (trimmedArgs.isEmpty() || !argMultimap.getPreamble().isEmpty()) { throw new ParseException( String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); } From e33d4cca830000e7ead8a73f5546730204e75571 Mon Sep 17 00:00:00 2001 From: sopa301 <96387349+sopa301@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:45:24 +0800 Subject: [PATCH 11/11] Add more tests --- .../address/logic/parser/FindCommandParserTest.java | 6 ++++++ .../seedu/address/logic/parser/ParserUtilTest.java | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java index 2effd3fb657..721f20901bf 100644 --- a/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java +++ b/src/test/java/seedu/address/logic/parser/FindCommandParserTest.java @@ -26,6 +26,12 @@ public void parse_emptyArg_throwsParseException() { String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); } + @Test + public void parse_invalidArg_throwsParseException() { + assertParseFailure(parser, " doo doo ", + String.format(MESSAGE_INVALID_COMMAND_FORMAT, FindCommand.MESSAGE_USAGE)); + } + @Test public void parse_validArgs_returnsFindCommand() { // no leading and trailing whitespaces diff --git a/src/test/java/seedu/address/logic/parser/ParserUtilTest.java b/src/test/java/seedu/address/logic/parser/ParserUtilTest.java index fddb6123b7a..a45e4ae8067 100644 --- a/src/test/java/seedu/address/logic/parser/ParserUtilTest.java +++ b/src/test/java/seedu/address/logic/parser/ParserUtilTest.java @@ -253,6 +253,10 @@ public void validateNames_invalidInputs_throwsParseException() { assertThrows(ParseException.class, () -> ParserUtil.validateNames(Arrays.asList(VALID_NAME_1, INVALID_NAME))); } @Test + public void validateNames_missingInputs_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateNames(Arrays.asList("", INVALID_NAME))); + } + @Test public void validateTag_validInput_success() { try { ParserUtil.validateTag(VALID_TAG_1); @@ -273,6 +277,10 @@ public void validateTags_validInputs_success() { } } @Test + public void validateTags_missingInputs_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateTags(Arrays.asList("", VALID_TAG_1))); + } + @Test public void validateTags_invalidInputs_throwsParseException() { assertThrows(ParseException.class, () -> ParserUtil.validateTags(Arrays.asList(VALID_NAME_1, INVALID_TAG))); } @@ -301,4 +309,9 @@ public void validateFinancialPlans_invalidInputs_throwsParseException() { assertThrows(ParseException.class, () -> ParserUtil.validateFinancialPlans( Arrays.asList(INVALID_FINANCIAL_PLAN, VALID_FINANCIAL_PLAN_1))); } + @Test + public void validateFinancialPlans_missingInputs_throwsParseException() { + assertThrows(ParseException.class, () -> ParserUtil.validateFinancialPlans( + Arrays.asList("", VALID_FINANCIAL_PLAN_1))); + } }