CI: an organization's live wall

9ce1a6e · gmorell · 2026-11-03 16:11

21 files +311 -92
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows.ex
+21 −4
@@ -1388,13 +1388,31 @@ defmodule GitGud.Workflows do
1388 1388 """
1389 1389 def list_running_jobs_for_user(user_id, repo_ids)
1390 1390 when is_integer(user_id) and is_list(repo_ids) do
1391 + running_jobs_query()
1392 + |> where([_j, r], r.repository_id in ^repo_ids or r.triggered_by_id == ^user_id)
1393 + |> Repo.all()
1394 + end
1395 +
1396 + @doc """
1397 + The jobs running right now in `repo_ids` — an organization's, as far
1398 + as the viewer may see them (visibility is the caller's, as for
1399 + `list_runs_for_repos/2`). Same order and preloads as
1400 + `list_running_jobs_for_user/2`.
1401 + """
1402 + def list_running_jobs_for_repos([]), do: []
1403 +
1404 + def list_running_jobs_for_repos(repo_ids) when is_list(repo_ids) do
1405 + running_jobs_query()
1406 + |> where([_j, r], r.repository_id in ^repo_ids)
1407 + |> Repo.all()
1408 + end
1409 +
1410 + defp running_jobs_query do
1391 1411 steps_query = from(s in WorkflowStep, order_by: s.number)
1392 1412
1393 1413 from(j in WorkflowJob,
1394 1414 join: r in assoc(j, :workflow_run),
1395 − where:
1396 − j.status == "running" and
1397 − (r.repository_id in ^repo_ids or r.triggered_by_id == ^user_id),
1415 + where: j.status == "running",
1398 1416 order_by: [asc_nulls_last: j.started_at, asc: j.id],
1399 1417 preload: [
1400 1418 :runner,
@@ -1402,7 +1420,6 @@ defmodule GitGud.Workflows do
1402 1420 workflow_run: {r, repository: [:owner, :organization]}
1403 1421 ]
1404 1422 )
1405 − |> Repo.all()
1406 1423 end
1407 1424
1408 1425 @doc """
renamed lib/git_gud_web/live/user_live/ci_wall.ex → lib/git_gud_web/live/ci_wall_live.ex
+93 −37
@@ -1,13 +1,19 @@
1 −defmodule GitGudWeb.UserLive.CiWall do
1 +defmodule GitGudWeb.CiWallLive do
2 2 @moduledoc """
3 − The live CI wall: every job running right now in the repositories a
4 − user cares about (`Workflows.list_running_jobs_for_user/2`), one card
5 − each, tiled to fill the screen below the nav (`CiWallLayout`).
3 + The live CI wall: every job running right now, one card each, tiled to
4 + fill the screen below the nav (`CiWallLive.Layout`). Two scopes, the
5 + same as the two run lists they sit beside:
6 6
7 − `/users/ci/live` puts the longest-running job first, at the left, full
8 − height, and the newest stacked at the right; `/users/ci/live/newest`
9 − reverses the order, so the newest job gets the room. `r` flips between
10 − the two.
7 + * `/users/ci/live` — the repositories a user cares about, and runs
8 + they triggered (`Workflows.list_running_jobs_for_user/2`), as on
9 + My CI
10 + * `/orgs/:handle/actions/live` — an organization's repositories the
11 + viewer may see (`Repositories.visible_repo_ids_for_org/2`), as on
12 + its Actions page, and to whoever may see that page
13 +
14 + Either puts the longest-running job first, at the left, full height,
15 + and the newest stacked at the right; `…/live/newest` reverses the
16 + order, so the newest job gets the room. `r` flips between the two.
11 17
12 18 Kept live three ways:
13 19
@@ -27,10 +33,11 @@ defmodule GitGudWeb.UserLive.CiWall do
27 33 use GitGudWeb, :live_view
28 34
29 35 alias GitGud.Git
36 + alias GitGud.Organizations
30 37 alias GitGud.Repositories
31 38 alias GitGud.Repositories.Storage
32 39 alias GitGud.Workflows
33 − alias GitGudWeb.UserLive.CiWallLayout
40 + alias GitGudWeb.CiWallLive.Layout
34 41
35 42 @max_cols 6
36 43 @tail_lines 40
@@ -38,38 +45,73 @@ defmodule GitGudWeb.UserLive.CiWall do
38 45 @flush_ms 200
39 46
40 47 @impl true
48 + def mount(%{"handle" => handle}, _session, socket) do
49 + org = Organizations.get_organization_by_handle!(handle)
50 + user = socket.assigns.current_scope && socket.assigns.current_scope.user
51 +
52 + if GitGudWeb.OrgLive.Actions.can_view?(org, user) do
53 + base = ~p"/orgs/#{org.handle}/actions/live"
54 +
55 + {:ok,
56 + socket
57 + |> assign(:scope, {:repos, Repositories.visible_repo_ids_for_org(org, user)})
58 + |> assign(:org, org)
59 + |> assign(:page_title, "Live CI — @#{org.handle}")
60 + |> assign(:oldest_path, base)
61 + |> assign(:newest_path, base <> "/newest")
62 + |> assign(:list_path, ~p"/orgs/#{org.handle}/actions")
63 + |> setup()}
64 + else
65 + {:ok,
66 + socket
67 + |> put_flash(:error, "You're not authorized to view this organization.")
68 + |> push_navigate(to: ~p"/")}
69 + end
70 + end
71 +
41 72 def mount(_params, _session, socket) do
42 73 user = socket.assigns.current_scope.user
43 74
44 − if connected?(socket),
45 − do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
46 −
47 75 {:ok,
48 76 socket
49 − |> assign(:user, user)
50 − |> assign(:repo_ids, Repositories.relevant_repo_ids_for_user(user))
77 + |> assign(:scope, {:user, user.id, Repositories.relevant_repo_ids_for_user(user)})
78 + |> assign(:org, nil)
51 79 |> assign(:page_title, "Live CI")
52 − |> assign(:jobs, [])
53 − |> assign(:tails, %{})
54 − |> assign(:step_jobs, %{})
55 − |> assign(:run_topics, MapSet.new())
56 − |> assign(:dirty_tails, MapSet.new())
57 − |> assign(:reload_pending?, false)
58 − |> assign(:flush_pending?, false)
59 − |> assign(:tiling, CiWallLayout.tile(0, @max_cols))
60 − |> assign(:columns, [])
61 − |> stream_configure(:cards, dom_id: &"ci-card-#{&1.id}")
62 − |> stream(:cards, [])}
80 + |> assign(:oldest_path, ~p"/users/ci/live")
81 + |> assign(:newest_path, ~p"/users/ci/live/newest")
82 + |> assign(:list_path, ~p"/users/ci")
83 + |> setup()}
84 + end
85 +
86 + defp setup(socket) do
87 + if connected?(socket),
88 + do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
89 +
90 + socket
91 + |> assign(:jobs, [])
92 + |> assign(:tails, %{})
93 + |> assign(:step_jobs, %{})
94 + |> assign(:run_topics, MapSet.new())
95 + |> assign(:dirty_tails, MapSet.new())
96 + |> assign(:reload_pending?, false)
97 + |> assign(:flush_pending?, false)
98 + |> assign(:tiling, Layout.tile(0, @max_cols))
99 + |> assign(:columns, [])
100 + |> stream_configure(:cards, dom_id: &"ci-card-#{&1.id}")
101 + |> stream(:cards, [])
63 102 end
64 103
65 104 @impl true
66 − def handle_params(_params, _uri, socket) do
105 + def handle_params(_params, _uri, %{assigns: %{scope: _}} = socket) do
67 106 {:noreply, socket |> assign(:order, socket.assigns.live_action) |> load()}
68 107 end
69 108
109 + # Turned away in `mount/3`, on its way out.
110 + def handle_params(_params, _uri, socket), do: {:noreply, socket}
111 +
70 112 @impl true
71 113 def handle_event("key", %{"key" => key}, socket) when key in ["r", "R"],
72 − do: {:noreply, push_patch(socket, to: flipped_path(socket.assigns.order))}
114 + do: {:noreply, push_patch(socket, to: flipped_path(socket.assigns))}
73 115
74 116 def handle_event("key", _params, socket), do: {:noreply, socket}
75 117
@@ -130,9 +172,9 @@ defmodule GitGudWeb.UserLive.CiWall do
130 172 # follow the jobs shown, and a job new to the wall starts with its log
131 173 # tail as stored.
132 174 defp load(socket) do
133 − %{user: user, repo_ids: repo_ids, tails: tails} = socket.assigns
175 + %{scope: scope, tails: tails} = socket.assigns
134 176
135 − jobs = Workflows.list_running_jobs_for_user(user.id, repo_ids)
177 + jobs = running_jobs(scope)
136 178 jobs = if socket.assigns.order == :newest, do: Enum.reverse(jobs), else: jobs
137 179
138 180 tails =
@@ -140,7 +182,7 @@ defmodule GitGudWeb.UserLive.CiWall do
140 182 {job.id, Map.get_lazy(tails, job.id, fn -> stored_tail(job) end)}
141 183 end)
142 184
143 − tiling = CiWallLayout.tile(length(jobs), @max_cols)
185 + tiling = Layout.tile(length(jobs), @max_cols)
144 186
145 187 socket =
146 188 socket
@@ -149,7 +191,7 @@ defmodule GitGudWeb.UserLive.CiWall do
149 191 |> assign(:tails, tails)
150 192 |> assign(:step_jobs, for(j <- jobs, s <- j.steps, into: %{}, do: {s.id, j.id}))
151 193 |> assign(:tiling, tiling)
152 − |> assign(:columns, CiWallLayout.column_counts(length(jobs), @max_cols))
194 + |> assign(:columns, Layout.column_counts(length(jobs), @max_cols))
153 195
154 196 cards =
155 197 jobs |> Enum.zip(tiling.spans) |> Enum.map(fn {job, span} -> card(socket, job, span) end)
@@ -204,8 +246,13 @@ defmodule GitGudWeb.UserLive.CiWall do
204 246
205 247 defp line(_), do: ""
206 248
207 − defp flipped_path(:newest), do: ~p"/users/ci/live"
208 − defp flipped_path(_oldest), do: ~p"/users/ci/live/newest"
249 + defp running_jobs({:user, user_id, repo_ids}),
250 + do: Workflows.list_running_jobs_for_user(user_id, repo_ids)
251 +
252 + defp running_jobs({:repos, repo_ids}), do: Workflows.list_running_jobs_for_repos(repo_ids)
253 +
254 + defp flipped_path(%{order: :newest, oldest_path: path}), do: path
255 + defp flipped_path(%{newest_path: path}), do: path
209 256
210 257 @impl true
211 258 def render(assigns) do
@@ -228,7 +275,16 @@ defmodule GitGudWeb.UserLive.CiWall do
228 275 if(@jobs == [], do: "bg-base-content/30", else: "bg-info")
229 276 ]} />
230 277 </span>
231 − <h1 class="font-semibold tracking-tight">Live CI</h1>
278 + <h1 class="font-semibold tracking-tight">
279 + Live CI
280 + <.link
281 + :if={@org}
282 + navigate={~p"/orgs/#{@org.handle}"}
283 + class="font-mono font-normal opacity-60 link link-hover ml-1"
284 + >
285 + @{@org.handle}
286 + </.link>
287 + </h1>
232 288 <span id="ci-wall-count" class="text-sm tabular-nums opacity-60">
233 289 {running_label(length(@jobs))}
234 290 </span>
@@ -257,7 +313,7 @@ defmodule GitGudWeb.UserLive.CiWall do
257 313 >
258 314 <.link
259 315 id="ci-wall-oldest"
260 − patch={~p"/users/ci/live"}
316 + patch={@oldest_path}
261 317 aria-current={@order == :oldest && "true"}
262 318 class={order_class(@order == :oldest)}
263 319 >
@@ -265,7 +321,7 @@ defmodule GitGudWeb.UserLive.CiWall do
265 321 </.link>
266 322 <.link
267 323 id="ci-wall-newest"
268 − patch={~p"/users/ci/live/newest"}
324 + patch={@newest_path}
269 325 aria-current={@order == :newest && "true"}
270 326 class={order_class(@order == :newest)}
271 327 >
@@ -274,7 +330,7 @@ defmodule GitGudWeb.UserLive.CiWall do
274 330 </div>
275 331 <kbd class="hidden lg:inline kbd kbd-xs opacity-60" title="Press r to reverse">r</kbd>
276 332 <.link
277 − navigate={~p"/users/ci"}
333 + navigate={@list_path}
278 334 id="ci-wall-list"
279 335 class="btn btn-ghost btn-xs gap-1 opacity-70 hover:opacity-100"
280 336 >
renamed lib/git_gud_web/live/user_live/ci_wall_layout.ex → lib/git_gud_web/live/ci_wall_live/layout.ex
+1 −1
@@ -1,4 +1,4 @@
1 −defmodule GitGudWeb.UserLive.CiWallLayout do
1 +defmodule GitGudWeb.CiWallLive.Layout do
2 2 @moduledoc """
3 3 How the live CI wall tiles `n` cards across at most `max_cols`
4 4 columns.
modified lib/git_gud_web/live/org_live/actions.ex
+16 −6
@@ -57,11 +57,12 @@ defmodule GitGudWeb.OrgLive.Actions do
57 57 defp pad(n) when n < 10, do: "0#{n}"
58 58 defp pad(n), do: "#{n}"
59 59
60 − defp can_view?(%{visibility: "public"}, _user), do: true
61 − defp can_view?(%{visibility: "internal"}, %{} = _user), do: true
62 − defp can_view?(%{visibility: "internal"}, nil), do: false
63 − defp can_view?(%{visibility: "private"} = org, %{} = user), do: Organizations.member?(org, user)
64 − defp can_view?(_, _), do: false
60 + @doc "Whether `user` (or nobody, signed out) may see the org's CI — here and on its live wall."
61 + def can_view?(%{visibility: "public"}, _user), do: true
62 + def can_view?(%{visibility: "internal"}, %{} = _user), do: true
63 + def can_view?(%{visibility: "internal"}, nil), do: false
64 + def can_view?(%{visibility: "private"} = org, %{} = user), do: Organizations.member?(org, user)
65 + def can_view?(_, _), do: false
65 66
66 67 defp load_runs(socket) do
67 68 repo_ids = socket.assigns.repo_ids
@@ -133,7 +134,16 @@ defmodule GitGudWeb.OrgLive.Actions do
133 134 @{@org.handle}
134 135 </.link>
135 136 </h1>
136 − <.filter_tabs current={@filter} active_count={@active_count} />
137 + <div class="flex items-center gap-3">
138 + <.link
139 + navigate={~p"/orgs/#{@org.handle}/actions/live"}
140 + id="org-ci-live-wall-link"
141 + class="btn btn-sm btn-ghost gap-1"
142 + >
143 + <.icon name="hero-squares-2x2-micro" class="size-4" /> Live wall
144 + </.link>
145 + <.filter_tabs current={@filter} active_count={@active_count} />
146 + </div>
137 147 </header>
138 148
139 149 <section :if={@duration_series != []} class="rounded-box border border-base-300 p-3">
modified lib/git_gud_web/router.ex
+6 −2
@@ -245,8 +245,8 @@ defmodule GitGudWeb.Router do
245 245 live "/orgs/:handle/settings/audit/:category/*refine", AuditLive.Org, :index
246 246 live "/users/ci", UserLive.Ci, :index
247 247 # The live wall of running jobs; the second reverses its order.
248 − live "/users/ci/live", UserLive.CiWall, :oldest
249 − live "/users/ci/live/newest", UserLive.CiWall, :newest
248 + live "/users/ci/live", CiWallLive, :oldest
249 + live "/users/ci/live/newest", CiWallLive, :newest
250 250 live "/r/:owner/:name/fork", RepoLive.Fork, :new
251 251 live "/orgs", OrgLive.Index, :index
252 252 live "/orgs/new", OrgLive.New, :new
@@ -310,6 +310,10 @@ defmodule GitGudWeb.Router do
310 310 live "/u/:handle/syms/:sym", SymsLive.Show, :show
311 311 live "/orgs/:handle", OrgLive.Show, :show
312 312 live "/orgs/:handle/actions", OrgLive.Actions, :index
313 + # Its live wall of running jobs, for whoever may see the page above
314 + # (a public org's to anyone); the second reverses its order.
315 + live "/orgs/:handle/actions/live", CiWallLive, :oldest
316 + live "/orgs/:handle/actions/live/newest", CiWallLive, :newest
313 317 live "/r/:owner/:name", RepoLive.Show, :show
314 318 live "/r/:owner/:name/tree/:ref/*path", RepoLive.Tree, :show
315 319 live "/r/:owner/:name/blob/:ref/*path", RepoLive.Blob, :show
added test/git_gud_web/live/ci_wall_live/layout_test.exs
+41 −0
@@ -0,0 +1,41 @@
1 +defmodule GitGudWeb.CiWallLive.LayoutTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias GitGudWeb.CiWallLive.Layout
5 +
6 + test "no cards, no grid" do
7 + assert Layout.tile(0, 6) == %{cols: 0, rows: 0, spans: []}
8 + end
9 +
10 + test "up to six cards, each a full-height column" do
11 + for n <- 1..6 do
12 + assert Layout.tile(n, 6) == %{cols: n, rows: 1, spans: List.duplicate(1, n)}
13 + end
14 + end
15 +
16 + test "the seventh card pairs the rightmost column; the oldest keep full height" do
17 + assert Layout.tile(7, 6) == %{cols: 6, rows: 2, spans: [2, 2, 2, 2, 2, 1, 1]}
18 + assert Layout.column_counts(7, 6) == [1, 1, 1, 1, 1, 2]
19 + end
20 +
21 + test "pairs fill in from the right until every column holds two" do
22 + assert Layout.tile(11, 6).spans == [2] ++ List.duplicate(1, 10)
23 + assert Layout.tile(12, 6) == %{cols: 6, rows: 2, spans: List.duplicate(1, 12)}
24 + end
25 +
26 + test "past that, threes fill in from the right the same way" do
27 + assert Layout.tile(13, 6) ==
28 + %{cols: 6, rows: 6, spans: List.duplicate(3, 10) ++ [2, 2, 2]}
29 +
30 + assert Layout.column_counts(13, 6) == [2, 2, 2, 2, 2, 3]
31 + end
32 +
33 + test "every column is filled exactly" do
34 + for n <- 1..40 do
35 + %{cols: cols, rows: rows, spans: spans} = Layout.tile(n, 6)
36 + assert length(spans) == n
37 + assert Enum.sum(spans) == cols * rows
38 + assert Enum.sum(Layout.column_counts(n, 6)) == n
39 + end
40 + end
41 +end
added test/git_gud_web/live/ci_wall_live/org_test.exs
+132 −0
@@ -0,0 +1,132 @@
1 +defmodule GitGudWeb.CiWallLive.OrgTest do
2 + @moduledoc """
3 + `/orgs/:handle/actions/live` — the live CI wall, narrowed to one
4 + organization, for whoever may see its Actions page. What matters is
5 + what leaks: jobs from outside the org, and jobs in the org's
6 + repositories the viewer may not see.
7 + """
8 + use GitGudWeb.ConnCase, async: false
9 +
10 + import GitGud.AccountsFixtures
11 + import GitGud.ForgeFixtures
12 + import Phoenix.LiveViewTest
13 +
14 + alias GitGud.Organizations
15 + alias GitGud.Repo
16 + alias GitGud.Repositories
17 + alias GitGud.Workflows.WorkflowJob
18 + alias GitGud.Workflows.WorkflowRun
19 +
20 + setup do
21 + owner = user_fixture()
22 +
23 + {:ok, org} =
24 + Organizations.create_organization(owner, %{
25 + "handle" => "org#{System.unique_integer([:positive])}",
26 + "name" => "Test Org"
27 + })
28 +
29 + {:ok, public} = org_repo(org, owner, "public")
30 + {:ok, private} = org_repo(org, owner, "private")
31 +
32 + {:ok, owner: owner, org: org, public: public, private: private}
33 + end
34 +
35 + defp org_repo(org, owner, visibility),
36 + do:
37 + Repositories.create_repository_for_org(org, owner, %{
38 + "name" => unique_repo_name(),
39 + "visibility" => visibility
40 + })
41 +
42 + defp running_job(repo) do
43 + run =
44 + %WorkflowRun{repository_id: repo.id}
45 + |> WorkflowRun.create_changeset(%{
46 + number: System.unique_integer([:positive]),
47 + workflow_path: ".forgejo/workflows/ci.yml",
48 + trigger: "push",
49 + head_sha: :crypto.strong_rand_bytes(20)
50 + })
51 + |> Ecto.Changeset.change(status: "running")
52 + |> Repo.insert!()
53 +
54 + %WorkflowJob{workflow_run_id: run.id}
55 + |> WorkflowJob.create_changeset(%{job_id: "build", name: "build", definition: %{}})
56 + |> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
57 + |> Repo.insert!()
58 + end
59 +
60 + defp card_ids(lv) do
61 + lv
62 + |> render()
63 + |> LazyHTML.from_fragment()
64 + |> LazyHTML.query("#ci-wall-cards > article")
65 + |> LazyHTML.attribute("id")
66 + |> Enum.sort()
67 + end
68 +
69 + defp path(org), do: "/orgs/#{org.handle}/actions/live"
70 +
71 + test "a member sees every running job in the org, and nothing outside it", %{
72 + conn: conn,
73 + owner: owner,
74 + org: org,
75 + public: public,
76 + private: private
77 + } do
78 + a = running_job(public)
79 + b = running_job(private)
80 + {_, personal} = repository_fixture(%{owner: owner})
81 + running_job(personal)
82 +
83 + {:ok, lv, _} = conn |> log_in_user(owner) |> live(path(org))
84 +
85 + assert card_ids(lv) == Enum.sort(["ci-card-#{a.id}", "ci-card-#{b.id}"])
86 + assert has_element?(lv, "#ci-wall-list[href='/orgs/#{org.handle}/actions']")
87 + end
88 +
89 + test "someone outside the org sees its public repositories' jobs only", %{
90 + conn: conn,
91 + org: org,
92 + public: public,
93 + private: private
94 + } do
95 + a = running_job(public)
96 + running_job(private)
97 +
98 + {:ok, lv, _} = conn |> log_in_user(user_fixture()) |> live(path(org))
99 + assert card_ids(lv) == ["ci-card-#{a.id}"]
100 +
101 + {:ok, lv, _} = live(conn, path(org))
102 + assert card_ids(lv) == ["ci-card-#{a.id}"]
103 + end
104 +
105 + test "the order toggle stays within the org", %{
106 + conn: conn,
107 + owner: owner,
108 + org: org,
109 + public: public
110 + } do
111 + running_job(public)
112 + {:ok, lv, _} = conn |> log_in_user(owner) |> live(path(org))
113 +
114 + render_keyup(lv, "key", %{"key" => "r"})
115 + assert_patch(lv, path(org) <> "/newest")
116 +
117 + render_keyup(lv, "key", %{"key" => "r"})
118 + assert_patch(lv, path(org))
119 + end
120 +
121 + test "a private org's wall turns away a non-member", %{conn: conn, owner: owner} do
122 + {:ok, secret} =
123 + Organizations.create_organization(owner, %{
124 + "handle" => "secret#{System.unique_integer([:positive])}",
125 + "name" => "Secret",
126 + "visibility" => "private"
127 + })
128 +
129 + assert {:error, {:live_redirect, %{to: "/"}}} =
130 + conn |> log_in_user(user_fixture()) |> live(path(secret))
131 + end
132 +end
renamed test/git_gud_web/live/user_live/ci_wall_test.exs → test/git_gud_web/live/ci_wall_live/user_test.exs
+1 −1
@@ -1,4 +1,4 @@
1 −defmodule GitGudWeb.UserLive.CiWallTest do
1 +defmodule GitGudWeb.CiWallLive.UserTest do
2 2 @moduledoc """
3 3 The live CI wall shows the jobs running in a user's repositories, one
4 4 card each, keeps up as jobs start, finish and log, and reverses its
deleted test/git_gud_web/live/user_live/ci_wall_layout_test.exs
+0 −41
@@ -1,41 +0,0 @@
1 −defmodule GitGudWeb.UserLive.CiWallLayoutTest do
2 − use ExUnit.Case, async: true
3 −
4 − alias GitGudWeb.UserLive.CiWallLayout
5 −
6 − test "no cards, no grid" do
7 − assert CiWallLayout.tile(0, 6) == %{cols: 0, rows: 0, spans: []}
8 − end
9 −
10 − test "up to six cards, each a full-height column" do
11 − for n <- 1..6 do
12 − assert CiWallLayout.tile(n, 6) == %{cols: n, rows: 1, spans: List.duplicate(1, n)}
13 − end
14 − end
15 −
16 − test "the seventh card pairs the rightmost column; the oldest keep full height" do
17 − assert CiWallLayout.tile(7, 6) == %{cols: 6, rows: 2, spans: [2, 2, 2, 2, 2, 1, 1]}
18 − assert CiWallLayout.column_counts(7, 6) == [1, 1, 1, 1, 1, 2]
19 − end
20 −
21 − test "pairs fill in from the right until every column holds two" do
22 − assert CiWallLayout.tile(11, 6).spans == [2] ++ List.duplicate(1, 10)
23 − assert CiWallLayout.tile(12, 6) == %{cols: 6, rows: 2, spans: List.duplicate(1, 12)}
24 − end
25 −
26 − test "past that, threes fill in from the right the same way" do
27 − assert CiWallLayout.tile(13, 6) ==
28 − %{cols: 6, rows: 6, spans: List.duplicate(3, 10) ++ [2, 2, 2]}
29 −
30 − assert CiWallLayout.column_counts(13, 6) == [2, 2, 2, 2, 2, 3]
31 − end
32 −
33 − test "every column is filled exactly" do
34 − for n <- 1..40 do
35 − %{cols: cols, rows: rows, spans: spans} = CiWallLayout.tile(n, 6)
36 − assert length(spans) == n
37 − assert Enum.sum(spans) == cols * rows
38 − assert Enum.sum(CiWallLayout.column_counts(n, 6)) == n
39 − end
40 − end
41 −end

Parents: 5e0e514