Gopher exercise in Elixir: timeout implementation - stuck!

So, I’ve started to work on these exercises but using Elixir: https://courses.calhoun.io/courses/cor_gophercises
specifically, the first one:
Part 1 Create a program that will read in a quiz provided via a CSV file (more details below) and will then give the quiz to a user keeping track of how many questions they get right and how many they get incorrect. Regardless of whether the answer is correct or wrong the next question should be asked immediately afterwards.
and
Part 2 Adapt your program from part 1 to add a timer. The default time limit should be 30 seconds, but should also be customizable via a flag. Your quiz should stop as soon as the time limit has exceeded. That is, you shouldn’t wait for the user to answer one final questions but should ideally stop the quiz entirely even if you are currently waiting on an answer from the end user. Users should be asked to press enter (or some other key) before the timer starts, and then the questions should be printed out to the screen one at a time until the user provides an answer. Regardless of whether the answer is correct or wrong the next question should be asked. At the end of the quiz the program should still output the total number of questions correct and how many questions there were in total. Questions given invalid answers or unanswered are considered incorrect.

So my Part 1 implementation is this:

defmodule CsvQuiz do
  @moduledoc """
  Documentation for CsvQuiz.
  """
  def csv_quiz(path) do
    [i, s] =
      parser(path)
      |> Enum.reduce([0, 0], fn array, [iteration, acc] -> 
                                solution(array, iteration, acc) end)

    IO.puts("\nYou scored #{s} of #{i}.")

    System.halt()
  end

  defp parser(path) do
    File.read!(path)
    |> String.split("\n")
    |> Enum.reject(fn x -> x == "" end)
    |> Enum.map(&String.split(&1, ","))
  end

  defp solution([question, answer], iteration, acc) do
    num = iteration + 1
    input = IO.gets("Problem ##{num}: #{question} = ") |> String.trim()

    result =
      if input == answer do
        acc + 1
      else
        acc
      end

    [num, result]
  end
end

and that is how to run it and what I have as an output:

❯ iex -S mix 

Erlang/OTP 22 [erts-10.4.1] [source] [64-bit] 
[smp:12:12] [ds:12:12:10] [async-threads:1] [hipe]

Compiling 1 file (.ex)
Interactive Elixir (1.9.0) - press Ctrl+C to exit (type h() ENTER for help)

iex(1)> CsvQuiz.csv_quiz("/gophercises_in_elixir/lib/1_csv_quiz_problems.csv")
Problem #1: 5+5 = 10
Problem #2: 1+1 = 2
Problem #3: 8+3 = 11
Problem #4: 1+2 = 3
Problem #5: 8+6 = 1
Problem #6: 3+1 = 1
Problem #7: 1+4 = 1
Problem #8: 5+1 = 1
Problem #9: 2+3 = 1
Problem #10: 3+3 = 1
Problem #11: 2+4 = 1
Problem #12: 5+2 = 1

You scored 4 of 12.

But I am stuck with Part 2, I mean not about the code itself, but about what tools do I need to use?

Could you please critic my Part 1 and suggest where to move according to Part 2 description.

I guess I need to use Task.yield() but I am not sure to be honest.

Thanks a lot in advance!

There may be easier solutions, but this seems like a use case for GenServer

For example, the below is a “quiz” where each “question” is just an integer, and the correct answer is the same integer. You kick it off by calling Quiz.start_quiz with a path and optional timeout. That in turn will call GenServer.start_link/2, which will end up triggering the init/1 callback in the # Server part of the code. In that init callback you can initialize the state of your server by reading in the CSV file. From there it just kicks off a loop through all the questions. The server will terminate either after all questions have been answer or the timeout, which ever comes first (the scheduling of the timeout is handled in handle_call{:next, ..., ...)

Whenever the server terminates, we print out the results in the terminate/2 callback. But note from the docs

… it is not guaranteed that terminate/2 is called when a GenServer exits. For such reasons, we usually recommend important clean-up rules to happen in separated processes either by use of monitoring or by links themselves

I am sure there is plenty of room for improvement in this implementation. For instance, if you are in the middle of a question when the server times out, the results will be printed to the screen but the prompt for the next answer remains active. I’m not sure how to fix that at the moment.

defmodule Quiz do
  use GenServer

  # Client

  def start_quiz(path, timeout \\ 30_000) do
    {:ok, pid} = GenServer.start_link(__MODULE__, %{path: path, timeout: timeout})

    IO.gets("press enter to continue\n")

    loop(pid)
  end

  defp loop(pid) do
    pid
    |> get_next_question()
    |> get_response()
    |> answer_current_question(pid)
    |> loop()
  end

  defp get_response(question) do
    IO.gets("#{question} = \n")
    |> String.trim()
  end

  defp get_next_question(pid) do
    GenServer.call(pid, :next)
  end

  defp answer_current_question(response, pid) do
    GenServer.call(pid, {:answer, response})
  end

  # Server

  @impl true
  def init(%{path: path, timeout: timeout}) do
    IO.inspect("starting quiz with path #{path}")
    questions = [1, 2, 3, 4, 5]
    total = length(questions)

    {:ok,
     %{timeout: timeout, correct: 0, total: total, current_question: nil, questions: questions}}
  end

  @impl true
  def terminate(:normal, %{correct: correct, total: total}) do
    IO.puts("Quiz over.  #{correct} questions answered correctly out of a total of #{total}")
  end

  @impl true
  def handle_call(:next, _from, state) do
    # if current_question is nil, that means this is
    # the first question, so we schedule the timer now
    if state.current_question == nil do
      schedule_timer(state)
    end

    handle_next_question(state)
  end

  @impl true
  def handle_call({:answer, response}, _from, %{current_question: question} = state) do
    new_state =
      case is_correct(response, question) do
        true -> Map.update!(state, :correct, &(&1 + 1))
        false -> state
      end

    {:reply, self(), new_state}
  end

  @impl true
  def handle_info(:end_quiz, state) do
    {:stop, :normal, state}
  end

  defp handle_next_question(%{questions: []} = state) do
    handle_info(:end_quiz, state)
  end

  defp handle_next_question(%{questions: [next | remaining]} = state) do
    new_state =
      state
      |> Map.put(:questions, remaining)
      |> Map.put(:current_question, next)

    {:reply, next, new_state}
  end

  defp is_correct(response, question) do
    response == Integer.to_string(question)
  end

  defp schedule_timer(state) do
    Process.send_after(self(), :end_quiz, state.timeout)
  end
end


1 Like