From 6b87c45d162e6ec1e135d0d016a36e478bb633bf Mon Sep 17 00:00:00 2001 From: Ryan Hartlage <2488333+ryanplusplus@users.noreply.github.com> Date: Sat, 9 May 2026 17:05:21 -0400 Subject: [PATCH] Add split-second-stopwatch --- config.json | 13 + .../practice/split-second-stopwatch/.busted | 5 + .../.docs/instructions.md | 22 ++ .../.docs/introduction.md | 6 + .../split-second-stopwatch/.meta/config.json | 19 ++ .../split-second-stopwatch/.meta/example.lua | 71 +++++ .../.meta/spec_generator.lua | 37 +++ .../split-second-stopwatch/.meta/tests.toml | 97 +++++++ .../split-second-stopwatch.lua | 37 +++ .../split-second-stopwatch_spec.lua | 248 ++++++++++++++++++ 10 files changed, 555 insertions(+) create mode 100644 exercises/practice/split-second-stopwatch/.busted create mode 100644 exercises/practice/split-second-stopwatch/.docs/instructions.md create mode 100644 exercises/practice/split-second-stopwatch/.docs/introduction.md create mode 100644 exercises/practice/split-second-stopwatch/.meta/config.json create mode 100644 exercises/practice/split-second-stopwatch/.meta/example.lua create mode 100644 exercises/practice/split-second-stopwatch/.meta/spec_generator.lua create mode 100644 exercises/practice/split-second-stopwatch/.meta/tests.toml create mode 100644 exercises/practice/split-second-stopwatch/split-second-stopwatch.lua create mode 100644 exercises/practice/split-second-stopwatch/split-second-stopwatch_spec.lua diff --git a/config.json b/config.json index 9f4329fc..5a064231 100644 --- a/config.json +++ b/config.json @@ -1489,6 +1489,19 @@ "practices": [], "prerequisites": [], "difficulty": 3 + }, + { + "slug": "split-second-stopwatch", + "name": "Split-Second Stopwatch", + "uuid": "49a0daf5-ecf8-41ad-8517-f969b770bf32", + "practices": [], + "prerequisites": [], + "difficulty": 3, + "topics": [ + "strings", + "text-formatting", + "time" + ] } ] }, diff --git a/exercises/practice/split-second-stopwatch/.busted b/exercises/practice/split-second-stopwatch/.busted new file mode 100644 index 00000000..86b84e7c --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.busted @@ -0,0 +1,5 @@ +return { + default = { + ROOT = { '.' } + } +} diff --git a/exercises/practice/split-second-stopwatch/.docs/instructions.md b/exercises/practice/split-second-stopwatch/.docs/instructions.md new file mode 100644 index 00000000..30bdc988 --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.docs/instructions.md @@ -0,0 +1,22 @@ +# Instructions + +Your task is to build a stopwatch to keep precise track of lap times. + +The stopwatch uses four commands (start, stop, lap, and reset) to keep track of: + +1. The current lap's tracked time +2. Previously recorded lap times + +What commands can be used depends on which state the stopwatch is in: + +1. Ready: initial state +2. Running: tracking time +3. Stopped: not tracking time + +| Command | Begin state | End state | Effect | +| ------- | ----------- | --------- | -------------------------------------------------------- | +| Start | Ready | Running | Start tracking time | +| Start | Stopped | Running | Resume tracking time | +| Stop | Running | Stopped | Stop tracking time | +| Lap | Running | Running | Add current lap to previous laps, then reset current lap | +| Reset | Stopped | Ready | Reset current lap and clear previous laps | diff --git a/exercises/practice/split-second-stopwatch/.docs/introduction.md b/exercises/practice/split-second-stopwatch/.docs/introduction.md new file mode 100644 index 00000000..a8432247 --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.docs/introduction.md @@ -0,0 +1,6 @@ +# Introduction + +You've always run for the thrill of it — no schedules, no timers, just the sound of your feet on the pavement. +But now that you've joined a competitive running crew, things are getting serious. +Training sessions are timed to the second, and every split second counts. +To keep pace, you've picked up the _Split-Second Stopwatch_ — a sleek, high-tech gadget that's about to become your new best friend. diff --git a/exercises/practice/split-second-stopwatch/.meta/config.json b/exercises/practice/split-second-stopwatch/.meta/config.json new file mode 100644 index 00000000..29e5d7b5 --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.meta/config.json @@ -0,0 +1,19 @@ +{ + "authors": [ + "ryanplusplus" + ], + "files": { + "solution": [ + "split-second-stopwatch.lua" + ], + "test": [ + "split-second-stopwatch_spec.lua" + ], + "example": [ + ".meta/example.lua" + ] + }, + "blurb": "Keep track of time through a digital stopwatch.", + "source": "Erik Schierboom", + "source_url": "https://github.com/exercism/problem-specifications/pull/2547" +} diff --git a/exercises/practice/split-second-stopwatch/.meta/example.lua b/exercises/practice/split-second-stopwatch/.meta/example.lua new file mode 100644 index 00000000..126daab0 --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.meta/example.lua @@ -0,0 +1,71 @@ +local function timestamp_from_seconds(seconds) + local hours = math.floor(seconds / 3600) + local minutes = math.floor((seconds % 3600) / 60) + local secs = seconds % 60 + return string.format('%02d:%02d:%02d', hours, minutes, secs) +end + +local function seconds_from_timestamp(timestamp) + local hours, minutes, seconds = timestamp:match("(%d+):(%d+):(%d+)") + return tonumber(hours) * 3600 + tonumber(minutes) * 60 + tonumber(seconds) +end + +local Stopwatch = {} + +function Stopwatch:new() + local obj = { _state = 'stopped' } + setmetatable(obj, self) + self.__index = self + obj:reset() + return obj +end + +function Stopwatch:start() + assert(self._state ~= 'running', 'cannot start an already running stopwatch') + self._state = 'running' +end + +function Stopwatch:stop() + assert(self._state == 'running', 'cannot stop a stopwatch that is not running') + self._state = 'stopped' +end + +function Stopwatch:reset() + assert(self._state == 'stopped', 'cannot reset a stopwatch that is not stopped') + self._state = 'ready' + self._total_time = 0 + self._lap_time = 0 + self._previous_laps = {} +end + +function Stopwatch:advance_time(timestamp) + if self._state == 'running' then + local time_in_seconds = seconds_from_timestamp(timestamp) + self._total_time = self._total_time + time_in_seconds + self._lap_time = self._lap_time + time_in_seconds + end +end + +function Stopwatch:total() + return timestamp_from_seconds(self._total_time) +end + +function Stopwatch:lap() + assert(self._state == 'running', 'cannot lap a stopwatch that is not running') + table.insert(self._previous_laps, timestamp_from_seconds(self._lap_time)) + self._lap_time = 0 +end + +function Stopwatch:current_lap() + return timestamp_from_seconds(self._lap_time) +end + +function Stopwatch:previous_laps() + return self._previous_laps +end + +function Stopwatch:state() + return self._state +end + +return Stopwatch diff --git a/exercises/practice/split-second-stopwatch/.meta/spec_generator.lua b/exercises/practice/split-second-stopwatch/.meta/spec_generator.lua new file mode 100644 index 00000000..19ae1445 --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.meta/spec_generator.lua @@ -0,0 +1,37 @@ +local utils = require 'utils' + +local function render_expected(expected) + if type(expected) == 'table' then + return '{ ' .. table.concat(utils.map(expected, utils.stringify), ', ') .. '}' + else + return utils.stringify(expected) + end +end + +return { + module_name = 'Stopwatch', + + generate_test = function(case) + local lines = {} + + for _, command in ipairs(case.input.commands) do + if command.command == 'new' then + table.insert(lines, 'local stopwatch = Stopwatch:new()') + elseif command.expected and command.expected.error then + table.insert(lines, + ('assert.has_error(function() stopwatch:%s() end, %s)'):format(utils.snake_case(command.command), + utils.stringify( + command.expected.error))) + elseif command.by then + table.insert(lines, ('stopwatch:%s(%s)'):format(utils.snake_case(command.command), render_expected(command.by))) + elseif command.expected then + table.insert(lines, ('assert.are.same(%s, stopwatch:%s())'):format(render_expected(command.expected), + utils.snake_case(command.command))) + else + table.insert(lines, ('stopwatch:%s()'):format(utils.snake_case(command.command))) + end + end + + return table.concat(lines, '\n') + end +} diff --git a/exercises/practice/split-second-stopwatch/.meta/tests.toml b/exercises/practice/split-second-stopwatch/.meta/tests.toml new file mode 100644 index 00000000..323cb7ae --- /dev/null +++ b/exercises/practice/split-second-stopwatch/.meta/tests.toml @@ -0,0 +1,97 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[ddb238ea-99d4-4eaa-a81d-3c917a525a23] +description = "new stopwatch starts in ready state" + +[b19635d4-08ad-4ac3-b87f-aca10e844071] +description = "new stopwatch's current lap has no elapsed time" + +[492eb532-268d-43ea-8a19-2a032067d335] +description = "new stopwatch's total has no elapsed time" + +[8a892c1e-9ef7-4690-894e-e155a1fe4484] +description = "new stopwatch does not have previous laps" + +[5b2705b6-a584-4042-ba3a-4ab8d0ab0281] +description = "start from ready state changes state to running" + +[748235ce-1109-440b-9898-0a431ea179b6] +description = "start does not change previous laps" + +[491487b1-593d-423e-a075-aa78d449ff1f] +description = "start initiates time tracking for current lap" + +[a0a7ba2c-8db6-412c-b1b6-cb890e9b72ed] +description = "start initiates time tracking for total" + +[7f558a17-ef6d-4a5b-803a-f313af7c41d3] +description = "start cannot be called from running state" + +[32466eef-b2be-4d60-a927-e24fce52dab9] +description = "stop from running state changes state to stopped" + +[621eac4c-8f43-4d99-919c-4cad776d93df] +description = "stop pauses time tracking for current lap" + +[465bcc82-7643-41f2-97ff-5e817cef8db4] +description = "stop pauses time tracking for total" + +[b1ba7454-d627-41ee-a078-891b2ed266fc] +description = "stop cannot be called from ready state" + +[5c041078-0898-44dc-9d5b-8ebb5352626c] +description = "stop cannot be called from stopped state" + +[3f32171d-8fbf-46b6-bc2b-0810e1ec53b7] +description = "start from stopped state changes state to running" + +[626997cb-78d5-4fe8-b501-29fdef804799] +description = "start from stopped state resumes time tracking for current lap" + +[58487c53-ab26-471c-a171-807ef6363319] +description = "start from stopped state resumes time tracking for total" + +[091966e3-ed25-4397-908b-8bb0330118f8] +description = "lap adds current lap to previous laps" + +[1aa4c5ee-a7d5-4d59-9679-419deef3c88f] +description = "lap resets current lap and resumes time tracking" + +[4b46b92e-1b3f-46f6-97d2-0082caf56e80] +description = "lap continues time tracking for total" + +[ea75d36e-63eb-4f34-97ce-8c70e620bdba] +description = "lap cannot be called from ready state" + +[63731154-a23a-412d-a13f-c562f208eb1e] +description = "lap cannot be called from stopped state" + +[e585ee15-3b3f-4785-976b-dd96e7cc978b] +description = "stop does not change previous laps" + +[fc3645e2-86cf-4d11-97c6-489f031103f6] +description = "reset from stopped state changes state to ready" + +[20fbfbf7-68ad-4310-975a-f5f132886c4e] +description = "reset resets current lap" + +[00a8f7bb-dd5c-43e5-8705-3ef124007662] +description = "reset clears previous laps" + +[76cea936-6214-4e95-b6d1-4d4edcf90499] +description = "reset cannot be called from ready state" + +[ba4d8e69-f200-4721-b59e-90d8cf615153] +description = "reset cannot be called from running state" + +[0b01751a-cb57-493f-bb86-409de6e84306] +description = "supports very long laps" diff --git a/exercises/practice/split-second-stopwatch/split-second-stopwatch.lua b/exercises/practice/split-second-stopwatch/split-second-stopwatch.lua new file mode 100644 index 00000000..dc0ad277 --- /dev/null +++ b/exercises/practice/split-second-stopwatch/split-second-stopwatch.lua @@ -0,0 +1,37 @@ +local Stopwatch = {} + +function Stopwatch:new() + local obj = {} + setmetatable(obj, self) + self.__index = self + return obj +end + +function Stopwatch:start() +end + +function Stopwatch:stop() +end + +function Stopwatch:reset() +end + +function Stopwatch:advance_time(timestamp) +end + +function Stopwatch:total() +end + +function Stopwatch:lap() +end + +function Stopwatch:current_lap() +end + +function Stopwatch:previous_laps() +end + +function Stopwatch:state() +end + +return Stopwatch diff --git a/exercises/practice/split-second-stopwatch/split-second-stopwatch_spec.lua b/exercises/practice/split-second-stopwatch/split-second-stopwatch_spec.lua new file mode 100644 index 00000000..7f0fd18e --- /dev/null +++ b/exercises/practice/split-second-stopwatch/split-second-stopwatch_spec.lua @@ -0,0 +1,248 @@ +local Stopwatch = require('split-second-stopwatch') + +describe('split-second-stopwatch', function() + it('new stopwatch starts in ready state', function() + local stopwatch = Stopwatch:new() + assert.are.same('ready', stopwatch:state()) + end) + + it("new stopwatch's current lap has no elapsed time", function() + local stopwatch = Stopwatch:new() + assert.are.same('00:00:00', stopwatch:current_lap()) + end) + + it("new stopwatch's total has no elapsed time", function() + local stopwatch = Stopwatch:new() + assert.are.same('00:00:00', stopwatch:total()) + end) + + it('new stopwatch does not have previous laps', function() + local stopwatch = Stopwatch:new() + assert.are.same({}, stopwatch:previous_laps()) + end) + + it('start from ready state changes state to running', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + assert.are.same('running', stopwatch:state()) + end) + + it('start does not change previous laps', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + assert.are.same({}, stopwatch:previous_laps()) + end) + + it('start initiates time tracking for current lap', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:05') + assert.are.same('00:00:05', stopwatch:current_lap()) + end) + + it('start initiates time tracking for total', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:23') + assert.are.same('00:00:23', stopwatch:total()) + end) + + it('start cannot be called from running state', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + assert.has_error(function() + stopwatch:start() + end, 'cannot start an already running stopwatch') + end) + + it('stop from running state changes state to stopped', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:stop() + assert.are.same('stopped', stopwatch:state()) + end) + + it('stop pauses time tracking for current lap', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:05') + stopwatch:stop() + stopwatch:advance_time('00:00:08') + assert.are.same('00:00:05', stopwatch:current_lap()) + end) + + it('stop pauses time tracking for total', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:13') + stopwatch:stop() + stopwatch:advance_time('00:00:44') + assert.are.same('00:00:13', stopwatch:total()) + end) + + it('stop cannot be called from ready state', function() + local stopwatch = Stopwatch:new() + assert.has_error(function() + stopwatch:stop() + end, 'cannot stop a stopwatch that is not running') + end) + + it('stop cannot be called from stopped state', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:stop() + assert.has_error(function() + stopwatch:stop() + end, 'cannot stop a stopwatch that is not running') + end) + + it('start from stopped state changes state to running', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:stop() + stopwatch:start() + assert.are.same('running', stopwatch:state()) + end) + + it('start from stopped state resumes time tracking for current lap', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:01:20') + stopwatch:stop() + stopwatch:advance_time('00:00:20') + stopwatch:start() + stopwatch:advance_time('00:00:08') + assert.are.same('00:01:28', stopwatch:current_lap()) + end) + + it('start from stopped state resumes time tracking for total', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:23') + stopwatch:stop() + stopwatch:advance_time('00:00:44') + stopwatch:start() + stopwatch:advance_time('00:00:09') + assert.are.same('00:00:32', stopwatch:total()) + end) + + it('lap adds current lap to previous laps', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:01:38') + stopwatch:lap() + assert.are.same({ '00:01:38' }, stopwatch:previous_laps()) + stopwatch:advance_time('00:00:44') + stopwatch:lap() + assert.are.same({ '00:01:38', '00:00:44' }, stopwatch:previous_laps()) + end) + + it('lap resets current lap and resumes time tracking', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:08:22') + stopwatch:lap() + assert.are.same('00:00:00', stopwatch:current_lap()) + stopwatch:advance_time('00:00:15') + assert.are.same('00:00:15', stopwatch:current_lap()) + end) + + it('lap continues time tracking for total', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:22') + stopwatch:lap() + stopwatch:advance_time('00:00:33') + assert.are.same('00:00:55', stopwatch:total()) + end) + + it('lap cannot be called from ready state', function() + local stopwatch = Stopwatch:new() + assert.has_error(function() + stopwatch:lap() + end, 'cannot lap a stopwatch that is not running') + end) + + it('lap cannot be called from stopped state', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:stop() + assert.has_error(function() + stopwatch:lap() + end, 'cannot lap a stopwatch that is not running') + end) + + it('stop does not change previous laps', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:11:22') + stopwatch:lap() + assert.are.same({ '00:11:22' }, stopwatch:previous_laps()) + stopwatch:stop() + assert.are.same({ '00:11:22' }, stopwatch:previous_laps()) + end) + + it('reset from stopped state changes state to ready', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:stop() + stopwatch:reset() + assert.are.same('ready', stopwatch:state()) + end) + + it('reset resets current lap', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:10') + stopwatch:stop() + stopwatch:reset() + assert.are.same('00:00:00', stopwatch:current_lap()) + end) + + it('reset clears previous laps', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('00:00:10') + stopwatch:lap() + stopwatch:advance_time('00:00:20') + stopwatch:lap() + assert.are.same({ '00:00:10', '00:00:20' }, stopwatch:previous_laps()) + stopwatch:stop() + stopwatch:reset() + assert.are.same({}, stopwatch:previous_laps()) + end) + + it('reset cannot be called from ready state', function() + local stopwatch = Stopwatch:new() + assert.has_error(function() + stopwatch:reset() + end, 'cannot reset a stopwatch that is not stopped') + end) + + it('reset cannot be called from running state', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + assert.has_error(function() + stopwatch:reset() + end, 'cannot reset a stopwatch that is not stopped') + end) + + it('supports very long laps', function() + local stopwatch = Stopwatch:new() + stopwatch:start() + stopwatch:advance_time('01:23:45') + assert.are.same('01:23:45', stopwatch:current_lap()) + stopwatch:lap() + assert.are.same({ '01:23:45' }, stopwatch:previous_laps()) + stopwatch:advance_time('04:01:40') + assert.are.same('04:01:40', stopwatch:current_lap()) + assert.are.same('05:25:25', stopwatch:total()) + stopwatch:lap() + assert.are.same({ '01:23:45', '04:01:40' }, stopwatch:previous_laps()) + stopwatch:advance_time('08:43:05') + assert.are.same('08:43:05', stopwatch:current_lap()) + assert.are.same('14:08:30', stopwatch:total()) + stopwatch:lap() + assert.are.same({ '01:23:45', '04:01:40', '08:43:05' }, stopwatch:previous_laps()) + end) +end)