#!/usr/bin/env elixir
# Combobox reference conformance fixture.
# Dogfoods the SHIPPED component (lib/.../components/combobox.ex) + its colocated hook
# + the SHIPPED LiveInteractionContracts.Channel guard (the production code path).
# Determinism: a server-side query/release gate (auto_release off => queries queue as
# pending; release buttons render a chosen version) lets the driver force out-of-order
# delivery with zero timing dependence. A guard toggle provides the NEGATIVE control:
# guard off bypasses Channel.accept and renders whatever is released (the spike's
# convention-only mode), so the driver can assert the helper is what prevents staleness.
# Run: elixir app.exs (http://127.0.0.1:4135)
Mix.install([
{:phoenix, "~> 1.8"},
{:phoenix_live_view, "~> 1.2"},
{:bandit, "~> 1.7"},
{:jason, "~> 1.4"}
])
Application.put_env(:phoenix, :json_library, Jason)
Code.require_file(Path.join(__DIR__, "../../lib/live_interaction_contracts/channel.ex"))
Code.require_file(Path.join(__DIR__, "../../lib/live_interaction_contracts/components/combobox.ex"))
Phoenix.LiveView.ColocatedAssets.compile()
colocated_dir =
Path.join([Mix.Project.build_path(), "phoenix-colocated", to_string(Mix.Project.config()[:app] || "")])
Application.put_env(:pr, :colocated_dir, colocated_dir)
Application.put_env(:pr, PRWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4135],
server: true,
adapter: Bandit.PhoenixAdapter,
live_view: [signing_salt: "combobox-ref-salt"],
secret_key_base: String.duplicate("combobox-reference-secret!", 4),
pubsub_server: PR.PubSub,
check_origin: false,
debug_errors: true
)
defmodule PRWeb.Layout do
use Phoenix.Component
@phoenix_js File.read!(Application.app_dir(:phoenix, "priv/static/phoenix.min.js"))
@lv_js File.read!(Application.app_dir(:phoenix_live_view, "priv/static/phoenix_live_view.min.js"))
def render("root.html", assigns) do
assigns = assign(assigns, phoenix_js: @phoenix_js, lv_js: @lv_js)
~H"""
Combobox reference
<%= @inner_content %>
"""
end
end
defmodule PRWeb.MainLive do
use Phoenix.LiveView
import LiveInteractionContracts.Components.Combobox
alias LiveInteractionContracts.Channel
@fruits ~w(apple apricot banana blueberry cherry grape kiwi lemon mango orange peach pear)
def mount(_p, _s, socket) do
{:ok,
assign(socket,
channel: Channel.new(),
version: 0,
results: [],
query: "",
pending: %{},
auto_release: true,
guard: true,
selected: nil,
select_count: 0,
sib: 0,
spacer: 40
)}
end
def render(assigns) do
~H"""
<.combobox id="cb" version={@version} on_input_change="query" on_value_change="select" placement="bottom">
<:item :for={r <- @results} id={r}>{r}
seen {@channel.version_seen} · rendered {@version} · drops {@channel.stale_drops} · guard {to_string(@guard)} · auto {to_string(@auto_release)}
{@query}
{@select_count}:{@selected || "none"}
sib {@sib}
"""
end
# Real component event path: note the version, queue the result set, and (in the
# ordinary typing path) release it immediately THROUGH THE GUARD.
def handle_event("query", %{"q" => q, "v" => v}, socket) do
socket =
socket
|> update(:channel, &Channel.note_query(&1, v))
|> update(:pending, &Map.put(&1, to_int(v), {q, filter(q)}))
if socket.assigns.auto_release,
do: {:noreply, do_release(socket, to_int(v))},
else: {:noreply, socket}
end
def handle_event("release", %{"v" => v}, socket), do: {:noreply, do_release(socket, to_int(v))}
def handle_event("select", %{"id" => id}, socket),
do: {:noreply, assign(socket, selected: id, select_count: socket.assigns.select_count + 1)}
def handle_event("toggle_auto", _, s), do: {:noreply, assign(s, auto_release: not s.assigns.auto_release)}
def handle_event("toggle_guard", _, s), do: {:noreply, assign(s, guard: not s.assigns.guard)}
def handle_event("sib", _, s), do: {:noreply, update(s, :sib, &(&1 + 1))}
def handle_event("move", _, s), do: {:noreply, assign(s, spacer: s.assigns.spacer + 150)}
def handle_event("bottom", _, s), do: {:noreply, assign(s, spacer: 660)}
def handle_event("reset", _, s), do: {:noreply, assign(s, spacer: 40)}
defp do_release(socket, v) do
case Map.fetch(socket.assigns.pending, v) do
{:ok, {q, results}} ->
if socket.assigns.guard do
# Production path: the shipped Channel guard decides.
case Channel.accept(socket.assigns.channel, v) do
{:ok, ch} ->
assign(socket, channel: ch, version: ch.rendered_version, query: q, results: results)
{:stale, ch} ->
assign(socket, channel: ch)
end
else
# NEGATIVE CONTROL: convention-only mode — render whatever was released.
assign(socket, version: v, query: q, results: results)
end
:error ->
socket
end
end
defp filter(""), do: []
defp filter(q), do: Enum.filter(@fruits, &String.starts_with?(&1, String.downcase(q)))
defp to_int(v) when is_integer(v), do: v
defp to_int(v) when is_binary(v), do: String.to_integer(v)
end
defmodule PRWeb.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
plug :put_root_layout, html: {PRWeb.Layout, :root}
end
scope "/" do
pipe_through :browser
live "/", PRWeb.MainLive
end
end
defmodule PRWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :pr
@session_options [store: :cookie, key: "_pr_key", signing_salt: "comboboxref", same_site: "Lax"]
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
plug Plug.Static,
at: "/colocated",
from: Application.compile_env!(:pr, :colocated_dir),
gzip: false
plug Plug.Session, @session_options
plug PRWeb.Router
end
{:ok, _} = Supervisor.start_link([{Phoenix.PubSub, name: PR.PubSub}, PRWeb.Endpoint], strategy: :one_for_one)
IO.puts("VERSIONS phoenix=#{Application.spec(:phoenix, :vsn)} live_view=#{Application.spec(:phoenix_live_view, :vsn)} elixir=#{System.version()} otp=#{System.otp_release()}")
IO.puts("READY http://127.0.0.1:4135")
Process.sleep(:infinity)