From 4da04dbad90cf081fdadd2e4a2a088264c43961e Mon Sep 17 00:00:00 2001 From: Jordi Pradel Date: Fri, 24 Feb 2023 10:24:48 +0100 Subject: [PATCH 1/8] Add console app and refactor --- app/build.gradle.kts | 6 +- .../domain => time}/TimeUtils.kt | 19 +++++- .../domain/TimeEntriesRepository.kt | 9 ++- .../agilogy/timetracking/domain/TimeEntry.kt | 13 +++- .../timetracking/domain/TimeTrackingApp.kt | 15 +++-- .../timetracking/domain/TimeTrackingAppPrd.kt | 39 ++++++++++-- .../PostgresTimeEntriesRepository.kt | 52 +++++++++++----- .../driveradapters/console/ArgsParser.kt | 61 +++++++++++++++++++ .../driveradapters/console/Command.kt | 16 +++++ .../driveradapters/console/Console.kt | 55 +++++++++++++++++ .../driveradapters/console/ConsoleApp.kt | 58 ++++++++++++++++++ .../domain/TimeTrackingAppTest.kt | 18 +++--- .../InMemoryTimeEntriesRepository.kt | 24 +++++--- .../TimeEntriesRepositoryTest.kt | 34 ++++++----- 14 files changed, 355 insertions(+), 64 deletions(-) rename app/src/main/kotlin/com/agilogy/{timetracking/domain => time}/TimeUtils.kt (53%) create mode 100644 app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt create mode 100644 app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt create mode 100644 app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt create mode 100644 app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8dbf0b4..6c68baf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,7 @@ import Dependencies.postgresql plugins { kotlin("jvm") version "1.8.10" - `java-library` + application } java { toolchain { languageVersion.set(JavaLanguageVersion.of(8)) } } @@ -28,6 +28,10 @@ kotlin { jvmToolchain(11) } +application { + mainClass.set("com.agilogy.timetracking.driveradapters.console.ConsoleAppKt") +} + dependencies { implementation(arrowCore) diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeUtils.kt b/app/src/main/kotlin/com/agilogy/time/TimeUtils.kt similarity index 53% rename from app/src/main/kotlin/com/agilogy/timetracking/domain/TimeUtils.kt rename to app/src/main/kotlin/com/agilogy/time/TimeUtils.kt index b191c84..ba3c96f 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeUtils.kt +++ b/app/src/main/kotlin/com/agilogy/time/TimeUtils.kt @@ -1,12 +1,15 @@ -package com.agilogy.timetracking.domain +package com.agilogy.time import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime +import java.time.YearMonth +import java.time.ZoneId import java.time.ZoneOffset import kotlin.time.Duration -fun LocalDateTime.toLocalInstant() = atZone(ZoneOffset.systemDefault()).toInstant() +fun LocalDateTime.toLocalInstant() = atZone(ZoneId.systemDefault()).toInstant() fun LocalDate.toLocalInstant() = atTime(0, 0).toLocalInstant() fun ClosedRange.toInstantRange(): ClosedRange = @@ -21,4 +24,14 @@ infix fun ClosedRange.intersection(other: ClosedRange): Closed fun > max(a: A, b: A) = if (a > b) a else b fun > min(a: A, b: A) = if (a <= b) a else b -fun Iterable.sum(): Duration = fold(Duration.ZERO) { acc, d -> acc + d } \ No newline at end of file +fun Iterable.sum(): Duration = fold(Duration.ZERO) { acc, d -> acc + d } + +fun YearMonth.toInstantRange(): ClosedRange = + atDay(1).atStartOfDay().atZone(ZoneOffset.systemDefault()).toInstant().. + atEndOfMonth().atTime(23, 59, 59).atZone(ZoneOffset.systemDefault()).toInstant() + +fun YearMonth.toLocalDateRange(): ClosedRange = + atDay(1)..atEndOfMonth() + +fun Instant.localTime(): LocalTime = atZone(ZoneId.systemDefault()).toLocalTime() +fun Instant.localDate(): LocalDate = atZone(ZoneId.systemDefault()).toLocalDate() \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt index e5110eb..a93b8dc 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntriesRepository.kt @@ -2,13 +2,16 @@ package com.agilogy.timetracking.domain import java.time.Instant import java.time.LocalDate +import java.time.LocalTime interface TimeEntriesRepository { suspend fun saveTimeEntries(timeEntries: List) - suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map + suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> suspend fun getDeveloperHoursByProjectAndDate( - developer: String, + developer: Developer, dateRange: ClosedRange, - ): List> + ): List> + + suspend fun listTimeEntries(timeRange: ClosedRange, developer: Developer?): List } diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt index 35e90bb..f0db1da 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeEntry.kt @@ -1,12 +1,21 @@ package com.agilogy.timetracking.domain +import com.agilogy.time.localDate import java.time.Instant import java.time.LocalDate import java.time.ZoneOffset import kotlin.time.Duration import kotlin.time.toKotlinDuration -data class TimeEntry(val developer: String, val project: String, val range: ClosedRange) { +data class TimeEntry(val developer: Developer, val project: Project, val range: ClosedRange) { val duration: Duration = java.time.Duration.between(range.start, range.endInclusive.plusNanos(1)).toKotlinDuration() - val localDate: LocalDate by lazy { range.start.atZone(ZoneOffset.systemDefault()).toLocalDate() } + val localDate: LocalDate by lazy { range.start.localDate() } } + + + +@JvmInline +value class Developer(val name: String) + +@JvmInline +value class Project(val name: String) \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt index 7d9b08a..22125cb 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingApp.kt @@ -1,14 +1,21 @@ package com.agilogy.timetracking.domain +import arrow.core.Tuple4 import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime interface TimeTrackingApp { - suspend fun saveTimeEntries(developer: String, timeEntries: List) - suspend fun getDeveloperHours(range: ClosedRange): Map + suspend fun saveTimeEntries(developer: Developer, timeEntries: List>>) + suspend fun getDeveloperHours(range: ClosedRange): Map, Hours> + suspend fun getDeveloperHoursByProjectAndDate(developer: Developer, dateRange: ClosedRange): + List> + + suspend fun listTimeEntries(dateRange: ClosedRange, developer: Developer?): + List>> } @JvmInline value class Hours(val value: Int) -data class DeveloperProject(val developer: String, val project: String) -data class DeveloperTimeEntry(val project: String, val range: ClosedRange) + diff --git a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt index 7696926..f58245c 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppPrd.kt @@ -1,13 +1,44 @@ package com.agilogy.timetracking.domain +import arrow.core.Tuple4 +import com.agilogy.time.localDate +import com.agilogy.time.localTime +import com.agilogy.time.toInstantRange import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime class TimeTrackingAppPrd(private val timeEntriesRepository: TimeEntriesRepository) : TimeTrackingApp { - override suspend fun saveTimeEntries(developer: String, timeEntries: List) { - timeEntriesRepository.saveTimeEntries(timeEntries.map { TimeEntry(developer, it.project, it.range) }) + override suspend fun saveTimeEntries(developer: Developer, timeEntries: List>>) { + timeEntriesRepository.saveTimeEntries(timeEntries.map { TimeEntry(developer, it.first, it.second) }) } - override suspend fun getDeveloperHours(range: ClosedRange): Map = - timeEntriesRepository.getHoursByDeveloperAndProject(range) + override suspend fun getDeveloperHours(range: ClosedRange): Map, Hours> = + timeEntriesRepository.getHoursByDeveloperAndProject(range) + + override suspend fun getDeveloperHoursByProjectAndDate(developer: Developer, dateRange: ClosedRange): + List> = + timeEntriesRepository.getDeveloperHoursByProjectAndDate(developer, dateRange) + + override suspend fun listTimeEntries(dateRange: ClosedRange, developer: Developer?): + List>> { + val timeEntries = timeEntriesRepository.listTimeEntries(dateRange.toInstantRange(), developer) + return timeEntries.flatMap { timeEntry -> + fun row(date: LocalDate, range: ClosedRange) = + Tuple4(timeEntry.developer, timeEntry.project, date, range) + + val res = if (timeEntry.range.endInclusive.localDate() != timeEntry.localDate) { + listOf( + row(timeEntry.localDate, timeEntry.range.start.localTime()..LocalTime.of(23, 59, 59)), + row(timeEntry.localDate.plusDays(1), LocalTime.of(0, 0)..timeEntry.range.endInclusive.localTime()) + ) + } else { + listOf( + row(timeEntry.localDate, timeEntry.range.start.localTime()..timeEntry.range.endInclusive.localTime()) + ) + } + res + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt b/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt index d999741..e106d01 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt @@ -1,20 +1,28 @@ package com.agilogy.timetracking.drivenadapters -import com.agilogy.timetracking.domain.DeveloperProject -import com.agilogy.timetracking.domain.Hours -import com.agilogy.timetracking.domain.TimeEntriesRepository -import com.agilogy.timetracking.domain.TimeEntry +import com.agilogy.db.sql.ResultSetView import com.agilogy.db.sql.Sql.batchUpdate import com.agilogy.db.sql.Sql.select import com.agilogy.db.sql.Sql.sql +import com.agilogy.db.sql.SqlParameter import com.agilogy.db.sql.param -import com.agilogy.timetracking.domain.toInstantRange +import com.agilogy.time.toInstantRange +import com.agilogy.timetracking.domain.Developer +import com.agilogy.timetracking.domain.Hours +import com.agilogy.timetracking.domain.Project +import com.agilogy.timetracking.domain.TimeEntriesRepository +import com.agilogy.timetracking.domain.TimeEntry import java.time.Instant import java.time.LocalDate import javax.sql.DataSource class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEntriesRepository { + private val Developer.param: SqlParameter get() = name.param + private val Project.param: SqlParameter get() = name.param + private fun ResultSetView.developer(columnIndex: Int): Developer? = string(columnIndex)?.let { Developer(it) } + private fun ResultSetView.project(columnIndex: Int): Project? = string(columnIndex)?.let { Project(it) } + companion object { val dbMigrations = listOf( """create table time_entries( @@ -30,34 +38,48 @@ class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEn override suspend fun saveTimeEntries(timeEntries: List) = dataSource.sql { val sql = """insert into time_entries(developer, project, start, "end") values (?, ?, ?, ?)""" batchUpdate(sql) { - timeEntries.forEach { addBatch(it.developer.param, it.project.param, it.range.start.param, it.range.endInclusive.param) } + timeEntries.forEach { + addBatch( + it.developer.param, it.project.param, it.range.start.param, it.range.endInclusive + .param + ) + } } Unit } - override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map = dataSource.sql { + override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> = dataSource.sql { val sql = """select developer, project, extract(EPOCH from sum("end" - start)) |from time_entries |where "end" > ? and start < ? |group by developer, project""".trimMargin() select(sql, range.start.param, range.endInclusive.param) { - DeveloperProject(it.string(1)!!, it.string(2)!!) to Hours((it.long(3)!! / 3_600).toInt()) - } + (it.developer(1)!! to it.project(2)!!) to Hours((it.long(3)!! / 3_600).toInt()) }.toMap() + } override suspend fun getDeveloperHoursByProjectAndDate( - developer: String, - dateRange: ClosedRange, - ): List> = dataSource.sql { + developer: Developer, + dateRange: ClosedRange + ): List> = dataSource.sql { val instantRange = dateRange.toInstantRange() val sql = """select date(start at time zone 'CEST'), project, extract(EPOCH from sum("end" - start)) |from time_entries - |where "start" > ? and start < ? + |where "start" > ? and start < ? and developer = ? |group by date(start at time zone 'CEST'), project |order by date(start at time zone 'CEST'), project |""".trimMargin() - select(sql, instantRange.start.param, instantRange.endInclusive.param) { - Triple(LocalDate.parse(it.string(1)!!), it.string(2)!!, Hours((it.long(3)!! / 3_600).toInt())) + select(sql, instantRange.start.param, instantRange.endInclusive.param, developer.param) { + Triple(LocalDate.parse(it.string(1)!!), it.project(2)!!, Hours((it.long(3)!! / 3_600).toInt())) + } + } + + override suspend fun listTimeEntries(timeRange: ClosedRange, developer: Developer?): List = dataSource.sql { + val sql = """select developer, project, start, "end" + |from time_entries + |where "end" > ? and start < ?""".trimMargin() + select(sql, timeRange.start.param, timeRange.endInclusive.param) { + TimeEntry(it.developer(1)!!, it.project(2)!!, it.timestamp(3)!!..it.timestamp(4)!!) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt new file mode 100644 index 0000000..3a96345 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ArgsParser.kt @@ -0,0 +1,61 @@ +package com.agilogy.timetracking.driveradapters.console + +import arrow.core.raise.Raise +import com.agilogy.timetracking.domain.Developer +import com.agilogy.timetracking.domain.Project +import java.time.Instant +import java.time.YearMonth + +data class ArgsParseError(val message: String) + +class ArgsParser { + + context(Raise) + fun parse(args: Array): Command { + fun arg(index: Int): String = if (index < args.size) args[index] else "" + + return if (args.isEmpty()) Help + else if (arg(0) == "report") when (args.size - 1) { + 0 -> GlobalReport(YearMonth.now()) + 1 -> GlobalReport(parseMonth(arg(1))) + 2 -> DeveloperReport(parseMonth(arg(1)), Developer(arg(2))) + else -> raise(ArgsParseError("Invalid number of arguments for command ${arg(0)}")) + } else if (arg(0) == "list") { + ListTimeEntries(parseMonth(arg(1)), args.getOrElse(2) { null }?.let { Developer(it) }) + } else if (arg(0) == "add") { + AddTimeEntry(Developer(arg(1)), Project(arg(2)), + parseInstant(arg(3))..parseInstant(arg(4)) + ) + } else raise(ArgsParseError("Unknown command ${arg(0)}")) + } + + context(Raise) + private fun parseMonth(value: String): YearMonth = parse("month", value) { YearMonth.parse(it) } + + context(Raise) + private fun parseInstant(value: String): Instant = parse("instant", value) { Instant.parse(it) } + + context(Raise) + private fun parse(type: String, value: String, parse: (String) -> A): A = + runCatching { parse(value) }.getOrElse { raise(ArgsParseError("Invalid $type $value")) } + + fun help(): String = + """ + Usage: timetracking [options] + + Commands: + add Adds a new time entry + developer: developer name + project: project name + start: start time in the format yyyy-MM-dd HH:mm + end: end time in the format yyyy-MM-dd HH:mm or just HH:mm + list [] Show the global time tracking report for the given month + month: month in the format yyyy-MM, defaults to current month + report Show the time tracking report for the given developer and month + month: month in the format yyyy-MM + developer: developer name + """.trimIndent() +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt new file mode 100644 index 0000000..f1419e2 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Command.kt @@ -0,0 +1,16 @@ +package com.agilogy.timetracking.driveradapters.console + +import com.agilogy.timetracking.domain.Developer +import com.agilogy.timetracking.domain.Project +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.YearMonth + +sealed interface Command + +object Help: Command +data class GlobalReport(val yearMonth: YearMonth): Command +data class DeveloperReport(val yearMonth: YearMonth, val developer: Developer): Command +data class ListTimeEntries(val yearMonth: YearMonth, val developer: Developer?): Command +data class AddTimeEntry(val developer: Developer, val project: Project, val range: ClosedRange): Command \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt new file mode 100644 index 0000000..f23ed29 --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/Console.kt @@ -0,0 +1,55 @@ +package com.agilogy.timetracking.driveradapters.console + +import arrow.core.Tuple4 +import com.agilogy.timetracking.domain.Developer +import com.agilogy.timetracking.domain.Hours +import com.agilogy.timetracking.domain.Project +import java.time.LocalDate +import java.time.LocalTime +import kotlin.math.max + +class Console { + + fun printHelp(message: String): Unit = println(message) + + fun print(report: Map, Hours>) { + val table = table( + report.map { (dp, hours) -> listOf(dp.first.name, dp.second.name, hours.value.toString()) }, + "Developer", "Project", "Hours" + ) + println(table) + } + + fun print(report: List>) { + val table = table( + report.map { (date, project, hours) -> listOf(date.toString(), project.name, hours.value.toString()) }, + "Date", "Project", "Hours" + ) + println(table) + } + + fun table(data: List>, vararg columns: String): String { + val columnLengths = columns.mapIndexed { i, header -> max(data.maxOfOrNull { it[i].length } ?: 0, header.length) } + val separators = columnLengths.map { "-" * it } + return listOf(columns.toList(), separators, * data.toTypedArray()).joinToString(separator = "\n") { row -> + row.zip(columnLengths).joinToString(" ") { (value, length) -> value.padEnd(length) } + } + } + + private operator fun String.times(n: Int): String = repeat(n) + + fun printTimeEntries(listTimeEntries: List>>): Unit = println( + table( + listTimeEntries.map { (developer, project, date, range) -> + listOf( + developer.name, + project.name, + date.toString(), + range.start.toString(), + range.endInclusive.toString() + ) + }, + "Developer", "Project", "Date", "Start", "End" + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt new file mode 100644 index 0000000..a55653e --- /dev/null +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt @@ -0,0 +1,58 @@ +package com.agilogy.timetracking.driveradapters.console + +import arrow.core.raise.effect +import arrow.core.raise.fold +import arrow.fx.coroutines.use +import com.agilogy.db.hikari.HikariCp +import com.agilogy.time.toInstantRange +import com.agilogy.time.toLocalDateRange +import com.agilogy.timetracking.domain.TimeTrackingApp +import com.agilogy.timetracking.domain.TimeTrackingAppPrd +import com.agilogy.timetracking.drivenadapters.PostgresTimeEntriesRepository +import java.time.ZoneOffset + +suspend fun main(args: Array): Unit = + HikariCp.dataSource("jdbc:postgresql://localhost/", "postgres", "postgres").use { dataSource -> + val timeEntriesRepository = PostgresTimeEntriesRepository(dataSource) + val timeTrackingApp = TimeTrackingAppPrd(timeEntriesRepository) + println("Your current zone id is ${ZoneOffset.systemDefault()}") + ConsoleApp(ArgsParser(), timeTrackingApp, Console()).main(args) + } + +class ConsoleApp( + private val argsParser: ArgsParser, + private val timeTrackingApp: TimeTrackingApp, + private val console: Console, +) { + + suspend fun main(args: Array) = + effect { runCommand(argsParser.parse(args)) }.fold( + { + println(it.message) + runCommand(Help) + }, + { + println("Command executed successfully") + } + ) + + private suspend fun runCommand(cmd: Command) { + when (cmd) { + is GlobalReport -> { + val report = timeTrackingApp.getDeveloperHours(cmd.yearMonth.toInstantRange()) + console.print(report) + } + + is DeveloperReport -> { + val report = timeTrackingApp.getDeveloperHoursByProjectAndDate(cmd.developer, cmd.yearMonth.toLocalDateRange()) + console.print(report) + } + + Help -> console.printHelp(argsParser.help()) + is ListTimeEntries -> + console.printTimeEntries(timeTrackingApp.listTimeEntries(cmd.yearMonth.toLocalDateRange(), cmd.developer)) + + is AddTimeEntry -> timeTrackingApp.saveTimeEntries(cmd.developer, listOf(cmd.project to cmd.range)) + } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt index 49757ce..ab2a277 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt @@ -10,14 +10,14 @@ class TimeTrackingAppTest : FunSpec() { val now = Instant.now() val hours = 1 val start = now.minusSeconds(hours * 3600L) - val developer = "John" - val project = "Acme Inc." + val developer = Developer("John") + val project = Project("Acme Inc.") test("Save time entries") { val timeEntriesRepository = InMemoryTimeEntriesRepository() val app = TimeTrackingAppPrd(timeEntriesRepository) - val developerTimeEntries = listOf(DeveloperTimeEntry(project, start..now)) + val developerTimeEntries = listOf(project to start..now) app.saveTimeEntries(developer, developerTimeEntries) val expected = listOf(TimeEntry(developer, project, start..now)) assertEquals(expected, timeEntriesRepository.getState()) @@ -28,7 +28,7 @@ class TimeTrackingAppTest : FunSpec() { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val result = app.getDeveloperHours(start..now) - val expected = mapOf(DeveloperProject(developer, project) to Hours(hours)) + val expected = mapOf((developer to project) to Hours(hours)) assertEquals(expected, result) } @@ -36,14 +36,14 @@ class TimeTrackingAppTest : FunSpec() { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val result = app.getDeveloperHours(start.plusSeconds(900)..now.minusSeconds(900)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(1)) + val expected = mapOf((developer to project) to Hours(1)) assertEquals(expected, result) } test("Get hours per developer when range is bigger than the developer hours") { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val result = app.getDeveloperHours(start.minusSeconds(7200L)..now.plusSeconds(7200L)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(1)) + val expected = mapOf((developer to project) to Hours(1)) assertEquals(expected, result) } xtest("Get hours per developer when range is outside the developer hours") { @@ -51,7 +51,7 @@ class TimeTrackingAppTest : FunSpec() { val app = TimeTrackingAppPrd(timeEntriesRepository) val resultLeft = app.getDeveloperHours(start.minusSeconds(3600L)..now.minusSeconds(7200L)) val resultRight = app.getDeveloperHours(start.plusSeconds(7200L)..now.plusSeconds(3600L)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(0)) + val expected = mapOf((developer to project) to Hours(0)) assertEquals(expected, resultLeft) assertEquals(expected, resultRight) } @@ -60,7 +60,7 @@ class TimeTrackingAppTest : FunSpec() { val app = TimeTrackingAppPrd(timeEntriesRepository) val resultOutside = app.getDeveloperHours(start.plusSeconds(7200L)..now.minusSeconds(7200L)) val resultInside = app.getDeveloperHours(start.plusSeconds(2700L)..now.minusSeconds(2700L)) - val expected = emptyMap() + val expected = emptyMap, Hours>() assertEquals(expected, resultOutside) assertEquals(expected, resultInside) } @@ -69,7 +69,7 @@ class TimeTrackingAppTest : FunSpec() { val app = TimeTrackingAppPrd(timeEntriesRepository) val resultStartInsideEndOutside = app.getDeveloperHours(start.plusSeconds(1600L)..now.plusSeconds(1600L)) val resultStartOutsideEndInside = app.getDeveloperHours(start.minusSeconds(1600L)..now.minusSeconds(1600L)) - val expected = mapOf(DeveloperProject(developer, project) to Hours(1)) + val expected = mapOf((developer to project) to Hours(1)) assertEquals(expected, resultStartInsideEndOutside) assertEquals(expected, resultStartOutsideEndInside) } diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt index cda061a..7c4aed7 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt @@ -1,12 +1,13 @@ package com.agilogy.timetracking.drivenadapters -import com.agilogy.timetracking.domain.DeveloperProject +import com.agilogy.time.intersection +import com.agilogy.time.sum +import com.agilogy.time.toInstantRange +import com.agilogy.timetracking.domain.Developer import com.agilogy.timetracking.domain.Hours +import com.agilogy.timetracking.domain.Project import com.agilogy.timetracking.domain.TimeEntriesRepository import com.agilogy.timetracking.domain.TimeEntry -import com.agilogy.timetracking.domain.intersection -import com.agilogy.timetracking.domain.sum -import com.agilogy.timetracking.domain.toInstantRange import java.time.Instant import java.time.LocalDate import kotlin.math.roundToInt @@ -23,9 +24,9 @@ class InMemoryTimeEntriesRepository(initialState: List = emptyList()) state.addAll(timeEntries) } - override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map = + override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> = state.filterIsIn(range) - .groupBy({ DeveloperProject(it.developer, it.project) }) { it.duration } + .groupBy({ it.developer to it.project }) { it.duration } .mapValues { Hours(it.value.sum().inWholeHours.toInt()) } private fun List.filterIsIn(range: ClosedRange) = mapNotNull { timeEntry -> @@ -33,9 +34,9 @@ class InMemoryTimeEntriesRepository(initialState: List = emptyList()) } override suspend fun getDeveloperHoursByProjectAndDate( - developer: String, - dateRange: ClosedRange, - ): List> = + developer: Developer, + dateRange: ClosedRange + ): List> = state .filter { it.developer == developer } .filterIsIn(dateRange.toInstantRange()) @@ -43,5 +44,10 @@ class InMemoryTimeEntriesRepository(initialState: List = emptyList()) .mapValues { Hours(((it.value.sum().inWholeSeconds) / 3600.0).roundToInt()) } .map { (k, v) -> Triple(k.first, k.second, v) } + override suspend fun listTimeEntries(timeRange: ClosedRange, developer: Developer?): List = + state + .filter { timeEntry -> developer?.let { it == timeEntry.developer } ?: true } + .filterIsIn(timeRange) + fun getState(): List = state.toList() } diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt index e4b1942..b908ae3 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt @@ -5,8 +5,9 @@ import com.agilogy.db.hikari.HikariCp import com.agilogy.db.postgresql.PostgreSql import com.agilogy.db.sql.Sql.sql import com.agilogy.db.sql.Sql.update -import com.agilogy.timetracking.domain.DeveloperProject +import com.agilogy.timetracking.domain.Developer import com.agilogy.timetracking.domain.Hours +import com.agilogy.timetracking.domain.Project import com.agilogy.timetracking.domain.TimeEntriesRepository import com.agilogy.timetracking.domain.TimeEntry import io.kotest.core.spec.style.FunSpec @@ -46,6 +47,11 @@ class TimeEntriesRepositoryTest : FunSpec() { init { + val d1 = Developer("d1") + val d2 = Developer("d2") + val p = Project("p") + val p2 = Project("p2") + beforeTest { withTestDataSource(null) { dataSource -> kotlin.runCatching { dataSource.sql { update("create database test") } } @@ -75,15 +81,15 @@ class TimeEntriesRepositoryTest : FunSpec() { val testDay = date(1) repo.saveTimeEntries( listOf( - TimeEntry("d1", "p", timePeriod(1, 9, 4)), - TimeEntry("d1", "p", timePeriod(1, 14, 3)), - TimeEntry("d2", "p", timePeriod(1, 10, 3)), + TimeEntry(d1, p, timePeriod(1, 9, 4)), + TimeEntry(d1, p, timePeriod(1, 14, 3)), + TimeEntry(d2, p, timePeriod(1, 10, 3)), ) ) assertEquals( mapOf( - DeveloperProject("d1", "p") to Hours(7), - DeveloperProject("d2", "p") to Hours(3), + Pair(d1, p) to Hours(7), + Pair(d2, p) to Hours(3), ), repo.getHoursByDeveloperAndProject(testDay.toLocalInstant()..testDay.plusDays(1).toLocalInstant()) ) @@ -93,20 +99,20 @@ class TimeEntriesRepositoryTest : FunSpec() { test("getDeveloperHoursByProjectAndDate") { repo -> repo.saveTimeEntries( listOf( - TimeEntry("d1", "p", timePeriod(1, 9, 1)), - TimeEntry("d1", "p", timePeriod(1, 11, 2)), - TimeEntry("d1", "p2", timePeriod(1, 14, 4)), - TimeEntry("d1", "p", timePeriod(2, 8, 6)), + TimeEntry(d1, p, timePeriod(1, 9, 1)), + TimeEntry(d1, p, timePeriod(1, 11, 2)), + TimeEntry(d1, p2, timePeriod(1, 14, 4)), + TimeEntry(d1, p, timePeriod(2, 8, 6)), ) ) assertEquals( listOf( - Triple(date(1), "p", Hours(3)), - Triple(date(1), "p2", Hours(4)), - Triple(date(2), "p", Hours(6)) + Triple(date(1), p, Hours(3)), + Triple(date(1), p2, Hours(4)), + Triple(date(2), p, Hours(6)) ), - repo.getDeveloperHoursByProjectAndDate("d1", date(1)..date(2)) + repo.getDeveloperHoursByProjectAndDate(d1, date(1)..date(2)) ) } From d37908573476fce1b7d8526035422a9d07ed0267 Mon Sep 17 00:00:00 2001 From: Hilari Gomez Date: Fri, 24 Feb 2023 12:15:45 +0100 Subject: [PATCH 2/8] Tests fixed --- .../agilogy/timetracking/domain/TimeTrackingAppTest.kt | 10 +++++----- .../drivenadapters/InMemoryTimeEntriesRepository.kt | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt index ab2a277..b0b7e1e 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt @@ -32,7 +32,7 @@ class TimeTrackingAppTest : FunSpec() { assertEquals(expected, result) } - xtest("Get hours per developer when range is inside the developer hours") { + test("Get hours per developer when range is inside the developer hours") { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val result = app.getDeveloperHours(start.plusSeconds(900)..now.minusSeconds(900)) @@ -46,16 +46,16 @@ class TimeTrackingAppTest : FunSpec() { val expected = mapOf((developer to project) to Hours(1)) assertEquals(expected, result) } - xtest("Get hours per developer when range is outside the developer hours") { + test("Get hours per developer when range is outside the developer hours") { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val resultLeft = app.getDeveloperHours(start.minusSeconds(3600L)..now.minusSeconds(7200L)) val resultRight = app.getDeveloperHours(start.plusSeconds(7200L)..now.plusSeconds(3600L)) - val expected = mapOf((developer to project) to Hours(0)) + val expected = emptyMap, Hours>() assertEquals(expected, resultLeft) assertEquals(expected, resultRight) } - xtest("Get hours per developer when range makes no sense") { + test("Get hours per developer when range makes no sense") { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val resultOutside = app.getDeveloperHours(start.plusSeconds(7200L)..now.minusSeconds(7200L)) @@ -64,7 +64,7 @@ class TimeTrackingAppTest : FunSpec() { assertEquals(expected, resultOutside) assertEquals(expected, resultInside) } - xtest("Get hours per developer when only one part of the range is inside") { + test("Get hours per developer when only one part of the range is inside") { val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) val app = TimeTrackingAppPrd(timeEntriesRepository) val resultStartInsideEndOutside = app.getDeveloperHours(start.plusSeconds(1600L)..now.plusSeconds(1600L)) diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt index 7c4aed7..e1a0b11 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/InMemoryTimeEntriesRepository.kt @@ -10,6 +10,7 @@ import com.agilogy.timetracking.domain.TimeEntriesRepository import com.agilogy.timetracking.domain.TimeEntry import java.time.Instant import java.time.LocalDate +import kotlin.math.ceil import kotlin.math.roundToInt class InMemoryTimeEntriesRepository(initialState: List = emptyList()) : TimeEntriesRepository { @@ -27,7 +28,7 @@ class InMemoryTimeEntriesRepository(initialState: List = emptyList()) override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> = state.filterIsIn(range) .groupBy({ it.developer to it.project }) { it.duration } - .mapValues { Hours(it.value.sum().inWholeHours.toInt()) } + .mapValues { Hours(ceil(it.value.sum().inWholeSeconds / 3_600.0).toInt()) } private fun List.filterIsIn(range: ClosedRange) = mapNotNull { timeEntry -> range.intersection(timeEntry.range)?.let { timeEntry.copy(range = it) } From 2f1f014eb98f69cb8595b39df045609109a017fe Mon Sep 17 00:00:00 2001 From: Hilari Gomez Date: Fri, 24 Feb 2023 13:09:21 +0100 Subject: [PATCH 3/8] Applications test to time repository test --- .../PostgresTimeEntriesRepository.kt | 9 ++- .../domain/TimeTrackingAppTest.kt | 42 ----------- .../TimeEntriesRepositoryTest.kt | 75 +++++++++++++++++-- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt b/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt index e106d01..1644c9e 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/drivenadapters/PostgresTimeEntriesRepository.kt @@ -15,6 +15,7 @@ import com.agilogy.timetracking.domain.TimeEntry import java.time.Instant import java.time.LocalDate import javax.sql.DataSource +import kotlin.math.ceil class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEntriesRepository { @@ -49,13 +50,13 @@ class PostgresTimeEntriesRepository(private val dataSource: DataSource) : TimeEn } override suspend fun getHoursByDeveloperAndProject(range: ClosedRange): Map, Hours> = dataSource.sql { - val sql = """select developer, project, extract(EPOCH from sum("end" - start)) + val sql = """select developer, project, extract(EPOCH from sum(least("end", ?) - greatest(start, ?))) |from time_entries |where "end" > ? and start < ? |group by developer, project""".trimMargin() - select(sql, range.start.param, range.endInclusive.param) { - (it.developer(1)!! to it.project(2)!!) to Hours((it.long(3)!! / 3_600).toInt()) - }.toMap() + select(sql, range.endInclusive.param, range.start.param, range.start.param, range.endInclusive.param) { + (it.developer(1)!! to it.project(2)!!) to Hours(ceil(it.long(3)!! / 3_600.0).toInt()) + }.toMap().filterValues { it.value > 0 } } override suspend fun getDeveloperHoursByProjectAndDate( diff --git a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt index b0b7e1e..5425c62 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt @@ -21,7 +21,6 @@ class TimeTrackingAppTest : FunSpec() { app.saveTimeEntries(developer, developerTimeEntries) val expected = listOf(TimeEntry(developer, project, start..now)) assertEquals(expected, timeEntriesRepository.getState()) - } test("Get hours per developer") { @@ -32,46 +31,5 @@ class TimeTrackingAppTest : FunSpec() { assertEquals(expected, result) } - test("Get hours per developer when range is inside the developer hours") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val result = app.getDeveloperHours(start.plusSeconds(900)..now.minusSeconds(900)) - val expected = mapOf((developer to project) to Hours(1)) - assertEquals(expected, result) - } - test("Get hours per developer when range is bigger than the developer hours") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val result = app.getDeveloperHours(start.minusSeconds(7200L)..now.plusSeconds(7200L)) - val expected = mapOf((developer to project) to Hours(1)) - assertEquals(expected, result) - } - test("Get hours per developer when range is outside the developer hours") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val resultLeft = app.getDeveloperHours(start.minusSeconds(3600L)..now.minusSeconds(7200L)) - val resultRight = app.getDeveloperHours(start.plusSeconds(7200L)..now.plusSeconds(3600L)) - val expected = emptyMap, Hours>() - assertEquals(expected, resultLeft) - assertEquals(expected, resultRight) - } - test("Get hours per developer when range makes no sense") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val resultOutside = app.getDeveloperHours(start.plusSeconds(7200L)..now.minusSeconds(7200L)) - val resultInside = app.getDeveloperHours(start.plusSeconds(2700L)..now.minusSeconds(2700L)) - val expected = emptyMap, Hours>() - assertEquals(expected, resultOutside) - assertEquals(expected, resultInside) - } - test("Get hours per developer when only one part of the range is inside") { - val timeEntriesRepository = InMemoryTimeEntriesRepository(listOf(TimeEntry(developer, project, start..now))) - val app = TimeTrackingAppPrd(timeEntriesRepository) - val resultStartInsideEndOutside = app.getDeveloperHours(start.plusSeconds(1600L)..now.plusSeconds(1600L)) - val resultStartOutsideEndInside = app.getDeveloperHours(start.minusSeconds(1600L)..now.minusSeconds(1600L)) - val expected = mapOf((developer to project) to Hours(1)) - assertEquals(expected, resultStartInsideEndOutside) - assertEquals(expected, resultStartOutsideEndInside) - } } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt index b908ae3..83fc5da 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt @@ -5,11 +5,7 @@ import com.agilogy.db.hikari.HikariCp import com.agilogy.db.postgresql.PostgreSql import com.agilogy.db.sql.Sql.sql import com.agilogy.db.sql.Sql.update -import com.agilogy.timetracking.domain.Developer -import com.agilogy.timetracking.domain.Hours -import com.agilogy.timetracking.domain.Project -import com.agilogy.timetracking.domain.TimeEntriesRepository -import com.agilogy.timetracking.domain.TimeEntry +import com.agilogy.timetracking.domain.* import io.kotest.core.spec.style.FunSpec import io.kotest.core.test.TestScope import org.junit.jupiter.api.Assertions.assertEquals @@ -47,6 +43,16 @@ class TimeEntriesRepositoryTest : FunSpec() { init { + val today = LocalDate.now() + + fun at(hour: Int, minute: Int = 0) = today.atTime(hour, minute).toInstant(ZoneOffset.UTC) + + val now = Instant.now() + val hours = 1 + val start = now.minusSeconds(hours * 3600L) + val developer = Developer("John") + val project = Project("Acme Inc.") + val d1 = Developer("d1") val d2 = Developer("d2") val p = Project("p") @@ -77,6 +83,10 @@ class TimeEntriesRepositoryTest : FunSpec() { } } + fun xtest(name: String, test: suspend TestScope.(TimeEntriesRepository) -> Unit) { + super.xtest(name) {} + } + test("getHoursByDeveloperAndProject") { repo -> val testDay = date(1) repo.saveTimeEntries( @@ -96,6 +106,61 @@ class TimeEntriesRepositoryTest : FunSpec() { } + test("Get hours per developer") {repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val result = repo.getHoursByDeveloperAndProject(start..now) + val expected = mapOf((developer to project) to Hours(hours)) + assertEquals(expected, result) + } + + test("Get hours per developer when range is bigger than the developer hours") {repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val result = repo.getHoursByDeveloperAndProject(start.minusSeconds(7200L)..now.plusSeconds(7200L)) + val expected = mapOf((developer to project) to Hours(1)) + assertEquals(expected, result) + } + + test("Get hours per developer when range makes no sense") {repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val resultOutside = repo.getHoursByDeveloperAndProject(start.plusSeconds(7200L)..now.minusSeconds(7200L)) + val resultInside = repo.getHoursByDeveloperAndProject(start.plusSeconds(2700L)..now.minusSeconds(2700L)) + val expected = emptyMap, Hours>() + assertEquals(expected, resultOutside) + assertEquals(expected, resultInside) + } + + test("getHoursByDeveloperAndProject returns the hours in the interval") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, at(9)..at(13)))) + val actual = repo.getHoursByDeveloperAndProject(at(10)..at(12)) + val expected = mapOf((developer to project) to Hours(2)) + assertEquals(expected, actual) + } + + test("getHoursByDeveloperAndProject rounds properly up") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, at(10)..at(10, 30)))) + val result = repo.getHoursByDeveloperAndProject(at(10)..at(11)) + val expected = mapOf((developer to project) to Hours(1)) + assertEquals(expected, result) + } + + test("Get hours per developer when range is outside the developer hours") {repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val resultLeft = repo.getHoursByDeveloperAndProject(start.minusSeconds(3600L)..now.minusSeconds(7200L)) + val resultRight = repo.getHoursByDeveloperAndProject(start.plusSeconds(7200L)..now.plusSeconds(3600L)) + val expected = emptyMap, Hours>() + assertEquals(expected, resultLeft) + assertEquals(expected, resultRight) + } + + test("Get hours per developer when only one part of the range is inside") { repo -> + repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) + val resultStartInsideEndOutside = repo.getHoursByDeveloperAndProject(start.plusSeconds(1600L)..now.plusSeconds(1600L)) + val resultStartOutsideEndInside = repo.getHoursByDeveloperAndProject(start.minusSeconds(1600L)..now.minusSeconds(1600L)) + val expected = mapOf((developer to project) to Hours(1)) + assertEquals(expected, resultStartInsideEndOutside) + assertEquals(expected, resultStartOutsideEndInside) + } + test("getDeveloperHoursByProjectAndDate") { repo -> repo.saveTimeEntries( listOf( From 1f1f0d592beba01422674da0b98b41436d04e0c8 Mon Sep 17 00:00:00 2001 From: Jordi Pradel Date: Fri, 24 Feb 2023 13:17:13 +0100 Subject: [PATCH 4/8] Document pending TODOs --- .../agilogy/timetracking/domain/TimeTrackingAppTest.kt | 4 ++++ .../drivenadapters/TimeEntriesRepositoryTest.kt | 3 ++- .../timetracking/driveradapters/ConsoleAppTest.kt | 9 +++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt diff --git a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt index 5425c62..fe4caa1 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/domain/TimeTrackingAppTest.kt @@ -31,5 +31,9 @@ class TimeTrackingAppTest : FunSpec() { assertEquals(expected, result) } + // TODO: Test the other methods of the app + + // TODO: Specially test the logic in listTimeEntries + } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt index 83fc5da..54b96ab 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt @@ -44,12 +44,13 @@ class TimeEntriesRepositoryTest : FunSpec() { init { val today = LocalDate.now() - fun at(hour: Int, minute: Int = 0) = today.atTime(hour, minute).toInstant(ZoneOffset.UTC) + // TODO: Migrate tests using now and start to use at(hour, minute) instead val now = Instant.now() val hours = 1 val start = now.minusSeconds(hours * 3600L) + val developer = Developer("John") val project = Project("Acme Inc.") diff --git a/app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt new file mode 100644 index 0000000..02687cc --- /dev/null +++ b/app/src/test/kotlin/com/agilogy/timetracking/driveradapters/ConsoleAppTest.kt @@ -0,0 +1,9 @@ +package com.agilogy.timetracking.driveradapters + +import io.kotest.core.spec.style.FunSpec + +class ConsoleAppTest: FunSpec() { + init{ + // TODO: Test ConsoleApp + } +} \ No newline at end of file From d9c92a84709bb211575f7d0860a73910a98cf733 Mon Sep 17 00:00:00 2001 From: Jordi Pradel Date: Fri, 28 Apr 2023 15:28:54 +0200 Subject: [PATCH 5/8] Fix TimeEntriesRepositoryTest wrong dates --- .../TimeEntriesRepositoryTest.kt | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt index 54b96ab..439a720 100644 --- a/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt +++ b/app/src/test/kotlin/com/agilogy/timetracking/drivenadapters/TimeEntriesRepositoryTest.kt @@ -1,10 +1,9 @@ package com.agilogy.timetracking.drivenadapters -import arrow.fx.coroutines.use import com.agilogy.db.hikari.HikariCp import com.agilogy.db.postgresql.PostgreSql +import com.agilogy.db.sql.Sql import com.agilogy.db.sql.Sql.sql -import com.agilogy.db.sql.Sql.update import com.agilogy.timetracking.domain.* import io.kotest.core.spec.style.FunSpec import io.kotest.core.test.TestScope @@ -14,18 +13,33 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.time.Month +import java.time.ZoneId import java.time.ZoneOffset import javax.sql.DataSource class TimeEntriesRepositoryTest : FunSpec() { private suspend fun withTestDataSource(database: String? = "test", f: suspend (DataSource) -> A) = - HikariCp.dataSource("jdbc:postgresql://localhost/${database ?: ""}", "postgres", "postgres").use { - dataSource -> f(dataSource) + HikariCp.dataSource("jdbc:postgresql://localhost:5432/${database ?: ""}", "postgres", "postgres").use { dataSource -> + f(dataSource) } - private suspend fun withPostgresTestRepo(f: suspend (TimeEntriesRepository) -> A) = - withTestDataSource { f(PostgresTimeEntriesRepository(it)) } + private suspend fun withPostgresTestRepo(f: suspend (TimeEntriesRepository) -> A) { + withTestDataSource(null) { dataSource -> + kotlin.runCatching { dataSource.sql { Sql.update("create database test") } } + .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.DuplicateDatabase }.getOrThrow() + } + + withTestDataSource { dataSource -> + println("Recreating table time_entries") + kotlin.runCatching { dataSource.sql { Sql.update("drop table time_entries") } } + .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.UndefinedTable }.getOrThrow() + PostgresTimeEntriesRepository.dbMigrations.forEach { dbMigration -> dataSource.sql { Sql.update(dbMigration) } } + f(PostgresTimeEntriesRepository(dataSource)) + } + + } private suspend fun withInMemoryTestRepo(f: suspend (TimeEntriesRepository) -> A) = f(InMemoryTimeEntriesRepository()) @@ -35,7 +49,9 @@ class TimeEntriesRepositoryTest : FunSpec() { private fun LocalDateTime.toLocalInstant() = atZone(ZoneOffset.systemDefault()).toInstant() private fun LocalDate.toLocalInstant() = atTime(0, 0).toLocalInstant() - private fun date(day: Int): LocalDate = LocalDate.of(2013, 2, day) + val today = LocalDate.of(2023, Month.APRIL, 1) + fun at(hour: Int, minute: Int = 0) = today.atTime(hour, minute).atZone(ZoneId.systemDefault()).toInstant() + private fun date(day: Int): LocalDate = LocalDate.of(2023, Month.APRIL, day) private fun timePeriod(day: Int, hourFrom: Int, hours: Int): ClosedRange { val from = date(day).atTime(LocalTime.of(hourFrom, 0)).toLocalInstant() return (from..from.plusSeconds(3600L * hours)) @@ -43,8 +59,6 @@ class TimeEntriesRepositoryTest : FunSpec() { init { - val today = LocalDate.now() - fun at(hour: Int, minute: Int = 0) = today.atTime(hour, minute).toInstant(ZoneOffset.UTC) // TODO: Migrate tests using now and start to use at(hour, minute) instead val now = Instant.now() @@ -59,20 +73,6 @@ class TimeEntriesRepositoryTest : FunSpec() { val p = Project("p") val p2 = Project("p2") - beforeTest { - withTestDataSource(null) { dataSource -> - kotlin.runCatching { dataSource.sql { update("create database test") } } - .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.DuplicateDatabase }.getOrThrow() - } - - withTestDataSource { dataSource -> - kotlin.runCatching { dataSource.sql { update("drop table time_entries") } } - .recoverIf(Unit) { it is PSQLException && it.sqlState == PostgreSql.UndefinedTable }.getOrThrow() - PostgresTimeEntriesRepository.dbMigrations.forEach { dbMigration -> dataSource.sql { update(dbMigration) } } - } - - } - fun test(name: String, test: suspend TestScope.(TimeEntriesRepository) -> Unit) { context(name) { this.test("Postgres") { @@ -84,6 +84,7 @@ class TimeEntriesRepositoryTest : FunSpec() { } } + @Suppress("UNUSED_PARAMETER") fun xtest(name: String, test: suspend TestScope.(TimeEntriesRepository) -> Unit) { super.xtest(name) {} } @@ -107,25 +108,25 @@ class TimeEntriesRepositoryTest : FunSpec() { } - test("Get hours per developer") {repo -> + test("Get hours per developer") { repo -> repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) - val result = repo.getHoursByDeveloperAndProject(start..now) + val result = repo.getHoursByDeveloperAndProject(start..now) val expected = mapOf((developer to project) to Hours(hours)) assertEquals(expected, result) } - test("Get hours per developer when range is bigger than the developer hours") {repo -> + test("Get hours per developer when range is bigger than the developer hours") { repo -> repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) val result = repo.getHoursByDeveloperAndProject(start.minusSeconds(7200L)..now.plusSeconds(7200L)) val expected = mapOf((developer to project) to Hours(1)) assertEquals(expected, result) } - test("Get hours per developer when range makes no sense") {repo -> + test("Get hours per developer when range makes no sense") { repo -> repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) val resultOutside = repo.getHoursByDeveloperAndProject(start.plusSeconds(7200L)..now.minusSeconds(7200L)) val resultInside = repo.getHoursByDeveloperAndProject(start.plusSeconds(2700L)..now.minusSeconds(2700L)) - val expected = emptyMap, Hours>() + val expected = emptyMap, Hours>() assertEquals(expected, resultOutside) assertEquals(expected, resultInside) } @@ -144,11 +145,11 @@ class TimeEntriesRepositoryTest : FunSpec() { assertEquals(expected, result) } - test("Get hours per developer when range is outside the developer hours") {repo -> + test("Get hours per developer when range is outside the developer hours") { repo -> repo.saveTimeEntries(listOf(TimeEntry(developer, project, start..now))) val resultLeft = repo.getHoursByDeveloperAndProject(start.minusSeconds(3600L)..now.minusSeconds(7200L)) val resultRight = repo.getHoursByDeveloperAndProject(start.plusSeconds(7200L)..now.plusSeconds(3600L)) - val expected = emptyMap, Hours>() + val expected = emptyMap, Hours>() assertEquals(expected, resultLeft) assertEquals(expected, resultRight) } From 4932dcf91fa8323f6be38d5aedb7c56bdff269ae Mon Sep 17 00:00:00 2001 From: Jordi Pradel Date: Fri, 28 Apr 2023 15:29:56 +0200 Subject: [PATCH 6/8] Fix Sql.transaction not setting autoCommit back to its initial value --- app/src/main/kotlin/com/agilogy/db/sql/Sql.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt b/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt index 22d8cc2..c405834 100644 --- a/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt +++ b/app/src/main/kotlin/com/agilogy/db/sql/Sql.kt @@ -31,13 +31,18 @@ object Sql { } } - suspend fun DataSource.sqlTransaction(isolationLevel: TransactionIsolationLevel, f: context(Connection) () -> A): A = + suspend fun DataSource.transaction(isolationLevel: TransactionIsolationLevel, f: context(Connection) () -> A): A = withContext(Dispatchers.IO) { connection.use { - with(it) { - autoCommit = false - transactionIsolation = isolationLevel.value - f(this).also { commit() } + val previousAutoCommit = it.autoCommit + try { + with(it) { + autoCommit = false + transactionIsolation = isolationLevel.value + f(this).also { commit() } + } + }finally{ + it.autoCommit = previousAutoCommit } } } From 9b4a55462a2a9f3bdc17c2029f1f3bfbdd352f9e Mon Sep 17 00:00:00 2001 From: Jordi Pradel Date: Fri, 28 Apr 2023 15:30:58 +0200 Subject: [PATCH 7/8] Configure ConsoleApp to use database test --- .../timetracking/driveradapters/console/ConsoleApp.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt index a55653e..5292165 100644 --- a/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt +++ b/app/src/main/kotlin/com/agilogy/timetracking/driveradapters/console/ConsoleApp.kt @@ -2,22 +2,23 @@ package com.agilogy.timetracking.driveradapters.console import arrow.core.raise.effect import arrow.core.raise.fold -import arrow.fx.coroutines.use import com.agilogy.db.hikari.HikariCp import com.agilogy.time.toInstantRange import com.agilogy.time.toLocalDateRange import com.agilogy.timetracking.domain.TimeTrackingApp import com.agilogy.timetracking.domain.TimeTrackingAppPrd import com.agilogy.timetracking.drivenadapters.PostgresTimeEntriesRepository +import kotlinx.coroutines.runBlocking import java.time.ZoneOffset -suspend fun main(args: Array): Unit = - HikariCp.dataSource("jdbc:postgresql://localhost/", "postgres", "postgres").use { dataSource -> +fun main(args: Array): Unit = runBlocking { + HikariCp.dataSource("jdbc:postgresql://localhost/test", "postgres", "postgres").use { dataSource -> val timeEntriesRepository = PostgresTimeEntriesRepository(dataSource) val timeTrackingApp = TimeTrackingAppPrd(timeEntriesRepository) println("Your current zone id is ${ZoneOffset.systemDefault()}") ConsoleApp(ArgsParser(), timeTrackingApp, Console()).main(args) } +} class ConsoleApp( private val argsParser: ArgsParser, From 2b913f2ef76492e008df91534368cf6c09bdf8dc Mon Sep 17 00:00:00 2001 From: Jordi Pradel Date: Fri, 28 Apr 2023 15:31:27 +0200 Subject: [PATCH 8/8] Upgrade Java, Kotlin and Arrow versions --- app/build.gradle.kts | 6 +++--- buildSrc/src/main/kotlin/Dependencies.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c68baf..fac2a56 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,11 +7,11 @@ import Dependencies.kotlinXSerializationJson import Dependencies.postgresql plugins { - kotlin("jvm") version "1.8.10" + kotlin("jvm") version "1.8.21" application } -java { toolchain { languageVersion.set(JavaLanguageVersion.of(8)) } } +java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } repositories { mavenCentral() @@ -25,7 +25,7 @@ tasks.withType { } kotlin { - jvmToolchain(11) + jvmToolchain(17) } application { diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index b94ae7c..16b6ec5 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -1,7 +1,7 @@ object Dependencies { val arrowKt = "io.arrow-kt" - val arrowVersion = "2.0.0-SNAPSHOT" + val arrowVersion = "1.1.6-alpha.57" val arrowCore = "$arrowKt:arrow-core:$arrowVersion" val arrowFxCoroutines = "$arrowKt:arrow-fx-coroutines:$arrowVersion" val arrowFxStm = "$arrowKt:arrow-fx-stm:$arrowVersion"