lib/test/NotebookCase.ex

defmodule NotebookUnit.Case do

  # Copyright 2019 Pieter Rijken
  #
  # Licensed under the Apache License, Version 2.0 (the "License");
  # you may not use this file except in compliance with the License.
  # You may obtain a copy of the License at
  #
  #     http://www.apache.org/licenses/LICENSE-2.0
  #
  # Unless required by applicable law or agreed to in writing, software
  # distributed under the License is distributed on an "AS IS" BASIS,
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  # See the License for the specific language governing permissions and
  # limitations under the License.

  defmacro __using__(options) do
    quote do
      import unquote(__MODULE__)
      import ExUnit.CaptureIO

      @compile warnings_as_errors: true
      @compile ignore_module_conflict: true

      Module.put_attribute __MODULE__, :nbdir, unquote(options)[:dir] || "."
    end
  end

  defmacro nbrun(json, _notebook) do
    quote do
      capture_io :stderr, fn ->
        capture_io fn ->
          try do
            {env, _} = Code.eval_quoted(quote do
              import IEx.Helpers
              __ENV__
            end)

            {:ok, _pid} = Boyle.start_link([])
            result = for {"cells",cells} <- unquote(json) do
              for cell <- cells, cell["cell_type"]=="code" do
                code = cell["source"]
                |> Enum.join("")
                |> String.replace("/app/notebooks/data", "notebooks/data")
                |> String.replace("/app/notebooks/images", "notebooks/images")
                {cell["execution_count"], code}
              end
            end
            |> List.flatten()
            |> Enum.reduce([out: [], binding: [ans: nil, out: %{}], env: env], fn {key,code}, acc ->
              block = { Code.string_to_quoted(code), quote(do: __ENV__) }
              {{result,env}, binding} = Code.eval_quoted(block, acc[:binding], acc[:env])
              new_binding = case result do
                :"do not show this result in output" -> binding
                :"this is an inline image" -> binding
                {:"this is an inline image",_} -> binding
                _ ->
                  binding
                  |> Keyword.update!(:ans,fn _ -> result end)
                  |> Keyword.update!(:out,& Map.put_new(&1,key,result))
              end
              [binding: new_binding, out: [{key,result}|acc[:out]], env: env]
            end)
            send self(), {:execute, result[:out]}
          rescue
            error -> send self(), {:error, error}
          after
            :ok = GenServer.stop Boyle
          end
        end
      end
    end
  end

  @mdheader ~r|#+ .*|
  def md_headers(json) do
    for {"cells",cells} <- json do
      for cell <- cells, cell["cell_type"]=="markdown" do
        cell["source"] |> Enum.filter(& Regex.match?(@mdheader,&1))
      end |> List.flatten()
    end |> List.flatten()
    |> Enum.map(& String.replace_leading(&1,"#",""))
    |> Enum.map(&String.trim/1)
  end

  @toc ~r|\#+ Table [oO]f [cC]ontents ?.*|
  @mdlink ~r|.*\[(?<hdr>.+)\]\(#(?<link>.+)\).*|
  @mdlistitem ~r| *\* +.*|
  def md_toc(json) do
    for {"cells",cells} <- json do
      for cell <- cells, cell["cell_type"]=="markdown", Regex.match?(@toc,hd(cell["source"])) do
        tl(cell["source"])
      end |> List.flatten()
    end |> List.flatten()
    |> Enum.filter(& Regex.match?(@mdlistitem,&1))
    |> Enum.map(& Regex.named_captures(@mdlink,&1))
  end

  defmacro nbtest(notebook) do
    quote do
      @tag notebooks: true
      @tag :notest
      test "Notebook - #{unquote notebook}" do
        json = (@nbdir <> "/" <> unquote notebook) |> File.read!() |> Poison.decode!()

        warns = nbrun json, unquote(notebook)
        assert_received {:execute, _out}
        assert warns == ""

        headers = md_headers(json)
        toc = md_toc(json)

        Enum.each(toc, fn %{"hdr" => hdr, "link" => link} ->
          assert hdr in headers, "Table of contents entry '#{hdr}' is missing"
          assert link in Enum.map(headers,& String.replace(&1," ","-")), "Table of contents '#{link}' is missing a corresponding header"
        end)
      end
    end
  end

end