Commit 1ac982f9 authored by Julien Topçu's avatar Julien Topçu
Browse files

Writing first recommendation feature

parent c9637e01
......@@ -193,6 +193,10 @@
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
......@@ -215,6 +219,10 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
......@@ -308,6 +316,12 @@
<version>3.11.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
<version>4.2.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
package org.craftsrecords.talkadvisor.recommendation.api;
import org.craftsrecords.talkadvisor.recommendation.Recommendation;
import org.craftsrecords.talkadvisor.recommendation.Topic;
import org.craftsrecords.talkadvisor.recommendation.criteria.Criteria;
@FunctionalInterface
public interface RecommendTalksForTopic{
Recommendation getRecommendation(Topic topic);
}
public interface RecommendTalksForTopic {
Recommendation getRecommendation(Criteria criteria);
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation
import org.craftsrecords.talkadvisor.recommendation.api.RecommendTalksForTopic
import org.craftsrecords.talkadvisor.recommendation.talk.Talk
class Recommendation() {
}
class Recommendation(val talks: Set<Talk>)
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation
import org.craftsrecords.talkadvisor.recommendation.api.RecommendTalksForTopic
import org.craftsrecords.talkadvisor.recommendation.criteria.Criteria
class Talk {
}
class TalksAdvisor : RecommendTalksForTopic {
override fun getRecommendation(criteria: Criteria): Recommendation {
TODO("not implemented")
}
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.criteria
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
class Criteria(val topic: Topic, talksFormats: Set<TalkFormat>) {
val talksFormats: Set<TalkFormat> = talksFormats.toHashSet()
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation
package org.craftsrecords.talkadvisor.recommendation.criteria
import org.apache.commons.lang3.Validate.notBlank
......
package org.craftsrecords.talkadvisor.recommendation.duration
import java.time.Duration
fun Duration.isPositive() = !(this.isZero || this.isNegative)
fun Duration.isInRange(range: ClosedRange<Duration>) = this.coerceIn(range) == this
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.talk
import org.apache.commons.lang3.Validate
import org.apache.commons.lang3.Validate.notBlank
import org.craftsrecords.talkadvisor.recommendation.duration.isPositive
import java.time.Duration
class Talk private constructor(id: String,
title: String,
duration: Duration) {
val id = notBlank(id, "Cannot create a Talk is a blank id")
val title = notBlank(title, "Cannot create a Talk is a blank title")
val duration = notNegative(duration)
val format = TalkFormat.ofDuration(duration)
private fun notNegative(duration: Duration): Duration {
Validate.isTrue(duration.isPositive(), "Talk duration must be strictly positive")
return duration
}
companion object {
inline fun with(content: Builder.() -> Unit) = Builder().apply(content)
}
class Builder {
lateinit var id: String
lateinit var title: String
lateinit var duration: Duration
fun build() = Talk(id, title, duration)
}
}
package org.craftsrecords.talkadvisor.recommendation.talk
import org.apache.commons.lang3.Validate.isTrue
import org.craftsrecords.talkadvisor.recommendation.duration.isInRange
import java.time.Duration
import java.time.Duration.ofHours
import java.time.Duration.ofMinutes
enum class TalkFormat(val format: String, private val durationRange: ClosedRange<Duration>) {
IGNITE("IGNITE", ofMinutes(1).rangeTo(ofMinutes(10).minusNanos(1))),
QUICKIE("QUICKIE", ofMinutes(10).rangeTo(ofMinutes(20).minusNanos(1))),
TOOL_IN_ACTION("TOOL_IN_ACTION", ofMinutes(20).rangeTo(ofMinutes(40).minusNanos(1))),
CONFERENCE("CONFERENCE", ofMinutes(40).rangeTo(ofMinutes(60).minusNanos(1))),
UNIVERSITY("UNIVERSITY", ofHours(1).rangeTo(ofHours(4)));
companion object Converter {
fun ofDuration(duration: Duration): TalkFormat {
isTrue(lessThanTheMinimumDuration(duration), "Duration is less than expected")
return values()
.singleOrNull { duration.isInRange(it.durationRange) }
?: UNIVERSITY
}
private fun lessThanTheMinimumDuration(duration: Duration): Boolean {
val firstRange = IGNITE.durationRange
return duration.coerceIn(firstRange) != firstRange.start
}
}
}
\ No newline at end of file
package org.craftsrecords.steps;
import cucumber.api.java8.En;
import org.craftsrecords.steps.context.TopicToLearn;
import org.craftsrecords.talkadvisor.recommendation.api.RecommendTalksForTopic;
public class TalkStepdefs implements En {
public TalkStepdefs(TopicToLearn topicToLearn, RecommendTalksForTopic recommendTalksForTopic) {
When("^he asks for the related talks$",
() -> recommendTalksForTopic.getRecommendation(topicToLearn.getTopic()));
}
}
package org.craftsrecords.steps;
import cucumber.api.java8.En;
import org.craftsrecords.steps.context.TopicToLearn;
public class UserStepdefs implements En {
public UserStepdefs(TopicToLearn topicToLearn) {
Given("^a guest user who wants to learn (.*)", topicToLearn::setTopic);
}
}
package org.craftsrecords.talkadvisor.recommendation.stepdefs;
import cucumber.api.java8.En;
import org.craftsrecords.talkadvisor.recommendation.criteria.Topic;
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat;
import java.util.Set;
import static java.util.Collections.singleton;
public class CriteriaStepdefs implements En {
public CriteriaStepdefs(TestContext testContext) {
Given("^a guest user who wants to learn (.+)",
(String topicName) -> testContext.setRequestedTopic(new Topic(topicName)));
Given("^he has only time to watch (.+) talks$",
(String format) -> {
Set<TalkFormat> talkFormats = singleton(TalkFormat.valueOf(format));
testContext.setRequestedTalksFormats(talkFormats);
});
}
}
package org.craftsrecords.talkadvisor.recommendation.stepdefs;
import cucumber.api.java8.En;
import kotlin.ranges.ClosedRange;
import org.craftsrecords.talkadvisor.recommendation.Recommendation;
import org.craftsrecords.talkadvisor.recommendation.api.RecommendTalksForTopic;
import org.craftsrecords.talkadvisor.recommendation.criteria.Criteria;
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat;
import java.time.Duration;
import static java.time.Duration.ofMinutes;
import static kotlin.ranges.RangesKt.rangeTo;
import static org.craftsrecords.talkadvisor.recommendation.assertions.TalkAdvisorAssertions.assertThat;
public class RecommendationStepdefs implements En {
public RecommendationStepdefs(TestContext testContext,
RecommendTalksForTopic recommendTalksForTopic) {
When("^he asks for the related talks$",
() -> {
Criteria criteria = createCriteriaFrom(testContext);
Recommendation recommendation = recommendTalksForTopic.getRecommendation(criteria);
testContext.setRecommendationResult(recommendation);
});
Then("^talkadvisor recommends some talks$", () -> {
Recommendation recommendation = testContext.getRecommendationResult();
assertThat(recommendation).hasTalks();
});
Then("^the recommended talks are related to (.+)",
(String topicName) -> {
Recommendation recommendation = testContext.getRecommendationResult();
assertThat(recommendation).hasTalksRelatedTo(topicName);
});
Then("^all the talks corresponding to the (.+) format have a duration between (\\d+) and (\\d+) minutes$",
(String format, Integer minDuration, Integer maxDuration) -> {
Recommendation recommendation = testContext.getRecommendationResult();
TalkFormat talkFormat = TalkFormat.valueOf(format);
ClosedRange<Duration> range = rangeTo(ofMinutes(minDuration), ofMinutes(maxDuration));
assertThat(recommendation).hasOnlyTalksInTheFormat(talkFormat);
assertThat(recommendation).hasOnlyTalksHavingTheirDurationIn(range);
});
}
private Criteria createCriteriaFrom(TestContext testContext) {
return new Criteria(testContext.getRequestedTopic(), testContext.getRequestedTalksFormats());
}
}
package org.craftsrecords.steps.context
import org.craftsrecords.talkadvisor.recommendation.Topic
class TopicToLearn(topic: String){
var topic = Topic(topic)
}
package org.craftsrecords.talkadvisor
import cucumber.runtime.java.picocontainer.PicoFactory
import org.craftsrecords.talkadvisor.recommendation.TalksAdvisor
import org.craftsrecords.talkadvisor.recommendation.stepdefs.TestContext
class CustomPicoFactory : PicoFactory() {
init {
addClass(TestContext::class.java)
addClass(TalksAdvisor::class.java)
}
}
\ No newline at end of file
package org.craftsrecords
package org.craftsrecords.talkadvisor
import cucumber.api.CucumberOptions
import cucumber.api.junit.Cucumber
......
package org.craftsrecords.talkadvisor.recommendation.assertions
import org.assertj.core.api.AbstractAssert
import org.craftsrecords.talkadvisor.recommendation.Recommendation
import org.craftsrecords.talkadvisor.recommendation.assertions.TalkAdvisorAssertions.Asserter.assertThat
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
import java.time.Duration
class RecommendationAssert(actual: Recommendation) : AbstractAssert<RecommendationAssert, Recommendation>(
actual,
RecommendationAssert::class.java
) {
@JvmName("hasTalks")
fun `has talks`() {
matches({ it.talks.isNotEmpty() }, "has talks")
}
@JvmName("hasTalksRelatedTo")
infix fun `has talks related to`(topicName: String) {
assertThat(actual.talks) `are related to topic` topicName
}
@JvmName("hasOnlyTalksInTheFormat")
infix fun `has only talks in the format`(talkFormat: TalkFormat) {
assertThat(actual.talks) `are in the format` talkFormat
}
@JvmName("hasOnlyTalksHavingTheirDurationIn")
fun `has only talks having their duration in`(range: ClosedRange<Duration>) {
assertThat(actual.talks) `have their duration in ` range
}
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.assertions
import org.assertj.core.api.Assertions
import org.craftsrecords.talkadvisor.recommendation.Recommendation
import org.craftsrecords.talkadvisor.recommendation.talk.Talk
class TalkAdvisorAssertions : Assertions() {
companion object Asserter {
@JvmStatic
fun assertThat(actual: Recommendation) = RecommendationAssert(actual)
@JvmStatic
fun assertThat(actual: Talk) = TalkAssert(actual)
@JvmStatic
fun assertThat(actual: Iterable<Talk>) = TalksAssert(actual)
}
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.assertions
import org.assertj.core.api.AbstractAssert
import org.assertj.core.api.AbstractIterableAssert
import org.craftsrecords.talkadvisor.recommendation.talk.Talk
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
import java.time.Duration
class TalksAssert(actual: Iterable<Talk>) : AbstractIterableAssert<TalksAssert, Iterable<Talk>, Talk, TalkAssert>(actual, TalksAssert::class.java) {
override fun newAbstractIterableAssert(actual: Iterable<Talk>): TalksAssert {
return TalksAssert(actual)
}
override fun toAssert(talk: Talk, description: String): TalkAssert {
return TalkAssert(talk).`as`(description)
}
@JvmName("areRelatedToTopic")
infix fun `are related to topic`(topicName: String) {
allSatisfy { TalkAssert(it) `is related to topic` topicName }
}
@JvmName("areInTheFormat")
infix fun `are in the format`(talkFormat: TalkFormat) {
allSatisfy {
TalkAssert(it) `is in the format` talkFormat
}
}
infix fun `have their duration in `(range: ClosedRange<Duration>) {
allSatisfy {
TalkAssert(it) `has its duration in` range
}
}
}
class TalkAssert(actual: Talk) : AbstractAssert<TalkAssert, Talk>(
actual,
TalkAssert::class.java
) {
@JvmName("isRelatedToTopic")
infix fun `is related to topic`(topicName: String) {
matches({ it.title.contains(topicName) }, "is related to topic $topicName")
}
@JvmName("isInTheFormat")
infix fun `is in the format`(talkFormat: TalkFormat) {
matches({ it.format == talkFormat }, "correspond explicitly to the format $talkFormat")
}
@JvmName("hasItsDurationIn")
infix fun `has its duration in`(range: ClosedRange<Duration>) {
matches({ it.duration.coerceIn(range) == it.duration }, "duration is in the expected range")
}
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.criteria
import org.assertj.core.api.Assertions.assertThat
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat.CONFERENCE
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat.QUICKIE
import org.junit.jupiter.api.Test
internal class CriteriaTest {
@Test
fun `should store a copy of the talks formats`() {
val talksFormats = mutableSetOf(CONFERENCE)
val criteria = Criteria(Topic("ddd"), talksFormats)
talksFormats.add(QUICKIE)
assertThat(criteria.talksFormats).containsOnly(CONFERENCE)
}
}
package org.craftsrecords.talkadvisor.recommendation
package org.craftsrecords.talkadvisor.recommendation.criteria
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.Test
import org.junit.jupiter.api.Test
internal class TopicTest{
internal class TopicTest {
@Test
fun `should not create a test with a blank name`(){
fun `should not create a test with a blank name`() {
assertThatThrownBy { Topic(" ") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessage("Cannot create a topic with a blank name")
......
package org.craftsrecords.talkadvisor.recommendation.duration
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.time.Duration.ZERO
import java.time.Duration.ofMinutes
internal class DurationExtensionTest {
@Test
fun `should be positive when greater than zero`() {
assertThat(ofMinutes(5).isPositive()).isTrue()
}
@Test
fun `should not be positive with a zero duration`() {
assertThat(ZERO.isPositive()).isFalse()
}
@Test
fun `should not be positive when lesser than zero`() {
assertThat(ofMinutes(-5).isPositive()).isFalse()
}
@Test
fun `should be in range`() {
val range = ofMinutes(-1).rangeTo(ofMinutes(1))
assertThat(ZERO.isInRange(range)).isTrue()
}
@Test
fun `should not be in range when lesser than start`() {
val range = ZERO.rangeTo(ofMinutes(1))
assertThat(ofMinutes(-1).isInRange(range)).isFalse()
}
@Test
fun `should not be in range when greater than end`() {
val range = ofMinutes(-1).rangeTo(ZERO)
assertThat(ofMinutes(1).isInRange(range)).isFalse()
}
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.stepdefs
import org.craftsrecords.talkadvisor.recommendation.Recommendation
import org.craftsrecords.talkadvisor.recommendation.criteria.Topic
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat
class TestContext {
lateinit var requestedTopic: Topic
lateinit var requestedTalksFormats: Set<TalkFormat>
lateinit var recommendationResult: Recommendation
}
\ No newline at end of file
package org.craftsrecords.talkadvisor.recommendation.talk
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat.CONFERENCE
import org.craftsrecords.talkadvisor.recommendation.talk.TalkFormat.QUICKIE
import org.junit.jupiter.api.Test
import java.time.Duration.ZERO
import java.time.Duration.ofMinutes
internal class TalkFormatTest {
@Test
fun `should return conference when duration is 45min`() {
val fortyFiveMinutes = ofMinutes(45)
assertThat(TalkFormat.ofDuration(fortyFiveMinutes)).isEqualTo(CONFERENCE)
}
@Test
fun `should return quickie when duration is 15min`() {
val fifteenMinutes = ofMinutes(15)
assertThat(TalkFormat.ofDuration(fifteenMinutes)).isEqualTo(QUICKIE)
}
@Test
fun `should not give a duration when less than the minimum of the smallest format`() {
assertThatThrownBy { TalkFormat.ofDuration(ZERO) }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessage("Duration is less than expected")
}
@Test
fun `should return university when duration is more than the maximum`() {
val fifteenMinutes = ofMinutes(15)
assertThat(TalkFormat.ofDuration(fifteenMinutes)).isEqualTo(QUICKIE)
}
}
\ No newline at end of file