mirror of
https://github.com/we-promise/sure.git
synced 2026-09-02 05:11:05 +00:00
* fix(insights): drop the dismiss toast and fix both empty states Three problems with acknowledging an insight, all in the turbo-stream path. Every dismissal appended an undo toast to the notification tray. Acknowledging already means "hidden until these numbers change" — GenerateInsightsJob resurfaces a row whose metadata moves materially, and 6 of 8 generators scope `dedup_key` to a month — so a toast interrupting the flow bought little. Removed, along with the now-orphaned `_undo_toast` partial and its two locale keys. Dismissing the last insight left the dashboard widget on screen: the stream re-rendered the well unconditionally, so the section shell stayed with its header above an empty box until a reload. A full render already drops it (PagesController#insights_feed_section sets `visible: @feed_insights.any?`); the stream now removes the whole section to match, targeted by `[data-section-key='insights_feed']` because the shared dashboard loop emits no id on the section element. Dismissing the last insight on /insights left a blank page. The card left via `turbo_stream.remove`, which emptied #insights-list without re-rendering the partial that owns the empty state, so "No insights yet" only appeared after a reload. The list is now replaced rather than the card removed — the same thing unacknowledge already did. InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept and still work; only the toast that reached them is gone, so undo can be re-wired to a different surface without resurrecting them. * fix(insights): announce the dismissal now the toast is gone Removing the undo toast took the only `role=status` element with it, and the stream also replaces the list containing the "Got it" control the user just activated — so a screen-reader or keyboard user was left with no confirmation that anything happened. Add a shared, visually hidden live region to the notification tray and update it from the acknowledge stream. It sits outside every stream target and is rendered with the page, which matters: a live region that arrives together with its own content is not announced. Updated rather than appended, so messages replace instead of piling up, and it stays empty (and free) until something uses it. This is a general primitive, not an insights one — the tray already holds `#sync-toast` and `#cta` as stable stream targets, and any flow that changes the page without leaving something on screen to read can use it. Verified in a browser: after dismissing, the region reads "Insight dismissed" at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible.
244 lines
8.7 KiB
Ruby
244 lines
8.7 KiB
Ruby
require "test_helper"
|
|
|
|
class InsightsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
enable_preview_features
|
|
@insight = insights(:spending_anomaly_dining)
|
|
ensure_tailwind_build
|
|
end
|
|
|
|
test "index renders visible insights and marks them read" do
|
|
get insights_url
|
|
|
|
assert_response :success
|
|
assert_match CGI.escapeHTML(@insight.title), response.body
|
|
assert @insight.reload.read?
|
|
end
|
|
|
|
test "turbo prefetch requests do not mark insights read" do
|
|
get insights_url, headers: { "X-Sec-Purpose" => "prefetch" }
|
|
|
|
assert_response :success
|
|
assert @insight.reload.active?
|
|
end
|
|
|
|
# Unread state is carried by the well's header count, not by a pill on every
|
|
# row. The widget shows three rows, so the pill was usually on all of them,
|
|
# repeating what the header already says and crowding each title.
|
|
test "dashboard insights feed counts unread in its header, without per-row badges" do
|
|
get root_url
|
|
|
|
assert_response :success
|
|
assert_select "#insights-feed", count: 1
|
|
assert_select "#insights-feed p", text: /#{Regexp.escape(I18n.t("insights.feed.header_new"))}/
|
|
assert_select "#insights-feed span", text: I18n.t("insights.card.new"), count: 0
|
|
end
|
|
|
|
test "insights feed leads the dashboard for users with a saved order that predates it" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge(
|
|
"section_order" => %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet]
|
|
))
|
|
|
|
get root_url
|
|
|
|
assert_response :success
|
|
feed_position = response.body.index('data-section-key="insights_feed"')
|
|
sankey_position = response.body.index('data-section-key="cashflow_sankey"')
|
|
assert feed_position.present? && feed_position < sankey_position,
|
|
"insights_feed should be prepended, not appended, for saved orders that predate it"
|
|
end
|
|
|
|
# Acknowledging is a quiet action — no undo toast. Acknowledgement only covers
|
|
# the numbers the user saw (see Insight's class comment), so a dismissed
|
|
# insight resurfaces on its own when those numbers move; a toast interrupting
|
|
# every dismissal bought little.
|
|
test "acknowledge clears the insight without an undo toast" do
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "turbo-stream", response.body
|
|
assert_no_match(/#{Regexp.escape(unacknowledge_insight_path(@insight))}/, response.body)
|
|
assert @insight.reload.acknowledged?
|
|
end
|
|
|
|
# The card leaves via a stream, which is silent to a screen reader, and takes
|
|
# the control the user just activated with it. With the toast gone, the shared
|
|
# live region carries that confirmation instead.
|
|
test "acknowledge announces the dismissal in the shared live region" do
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_select "turbo-stream[action=update][target=?]", "aria-announcer" do
|
|
assert_match CGI.escapeHTML(I18n.t("insights.card.acknowledged")), response.body
|
|
end
|
|
end
|
|
|
|
test "the live region is present before any stream updates it" do
|
|
get insights_url
|
|
|
|
assert_response :success
|
|
assert_select "#aria-announcer[role=status][aria-live=polite]", 1,
|
|
"a live region that arrives with its content is not announced"
|
|
end
|
|
|
|
# The card used to leave via `turbo_stream.remove`, which emptied
|
|
# #insights-list without re-rendering the partial that owns the empty state —
|
|
# so dismissing the last insight showed a blank page until reload.
|
|
test "acknowledging the last insight renders the empty state" do
|
|
@user.family.insights.where.not(id: @insight.id).destroy_all
|
|
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "insights-list", response.body
|
|
assert_match CGI.escapeHTML(I18n.t("insights.index.empty.title")), response.body
|
|
end
|
|
|
|
# A full dashboard render already drops the section (insights_feed_section sets
|
|
# `visible: @feed_insights.any?`), so the turbo path has to as well — otherwise
|
|
# the section shell lingered with its header above an empty well.
|
|
test "acknowledging the last insight removes the dashboard section" do
|
|
@user.family.insights.where.not(id: @insight.id).destroy_all
|
|
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_select "turbo-stream[action=remove][targets=?]", "[data-section-key='insights_feed']"
|
|
assert_select "turbo-stream[action=replace][target=?]", "insights-feed", count: 0
|
|
end
|
|
|
|
test "unacknowledge restores the insight as read and re-renders the list" do
|
|
@insight.acknowledge!
|
|
|
|
patch unacknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "insights-list", response.body
|
|
assert_match CGI.escapeHTML(@insight.title), response.body
|
|
assert @insight.reload.read?
|
|
assert_nil @insight.dismissed_at
|
|
end
|
|
|
|
# Acknowledging used to be reachable only from /insights, so the dashboard —
|
|
# the surface people actually look at — could show an insight but not clear it.
|
|
test "dashboard feed rows carry an acknowledge control" do
|
|
get root_url
|
|
|
|
assert_response :success
|
|
assert_select "#insights-feed form[action=?]", acknowledge_insight_path(@insight)
|
|
end
|
|
|
|
# The widget shows the top N, so clearing one has to promote the next into the
|
|
# freed slot rather than leave a gap — hence a re-render, not a row removal.
|
|
test "acknowledge re-renders the dashboard feed so the next insight backfills" do
|
|
family = @user.family
|
|
family.insights.destroy_all
|
|
|
|
# One more than the well holds, same priority so `ordered` falls through to
|
|
# generated_at and the sequence is predictable.
|
|
rows = (Insight::FEED_LIMIT + 1).times.map do |i|
|
|
family.insights.create!(
|
|
insight_type: "idle_cash",
|
|
priority: "high",
|
|
status: "active",
|
|
title: "Test insight #{i}",
|
|
body: "body",
|
|
dedup_key: "idle_cash:test:#{i}",
|
|
generated_at: (i + 1).minutes.ago
|
|
)
|
|
end
|
|
|
|
patch acknowledge_insight_url(rows.first), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "insights-feed", response.body
|
|
assert_no_match(/Test insight 0/, response.body)
|
|
assert_match(/Test insight #{Insight::FEED_LIMIT}/, response.body)
|
|
end
|
|
|
|
test "refresh swaps the button into a pending state via turbo stream" do
|
|
assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @user.family_id } ]) do
|
|
post refresh_insights_url, as: :turbo_stream
|
|
end
|
|
|
|
assert_response :success
|
|
assert_match "insights-refresh", response.body
|
|
assert_match CGI.escapeHTML(I18n.t("insights.refresh.checking")), response.body
|
|
end
|
|
|
|
test "cannot acknowledge another family's insight" do
|
|
other_insight = families(:empty).insights.create!(
|
|
insight_type: "idle_cash",
|
|
priority: "low",
|
|
title: "Someone else's insight",
|
|
body: "Body",
|
|
dedup_key: "idle_cash:other:2026-07"
|
|
)
|
|
|
|
patch acknowledge_insight_url(other_insight), as: :turbo_stream
|
|
|
|
assert_response :not_found
|
|
assert other_insight.reload.active?
|
|
end
|
|
|
|
test "refresh enqueues insight generation for the family" do
|
|
assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @user.family_id } ]) do
|
|
post refresh_insights_url
|
|
end
|
|
|
|
assert_redirected_to insights_path
|
|
end
|
|
|
|
# Preview gate. Insights is opt-in via Settings → Preferences, so a user
|
|
# without the flag reaches none of it — not the page, not the dashboard
|
|
# section, not the top-bar entry, and not the job the refresh action would
|
|
# otherwise enqueue.
|
|
test "redirects users without preview access" do
|
|
disable_preview_features
|
|
|
|
get insights_url
|
|
|
|
assert_redirected_to root_path
|
|
assert_match(/preview/i, flash[:alert])
|
|
end
|
|
|
|
test "refresh does not enqueue generation for users without preview access" do
|
|
disable_preview_features
|
|
|
|
assert_no_enqueued_jobs only: GenerateInsightsJob do
|
|
post refresh_insights_url
|
|
end
|
|
|
|
assert_redirected_to root_path
|
|
end
|
|
|
|
test "acknowledge is blocked for users without preview access" do
|
|
disable_preview_features
|
|
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_redirected_to root_path
|
|
assert @insight.reload.active?
|
|
end
|
|
|
|
test "dashboard omits the insights feed and top-bar entry without preview access" do
|
|
disable_preview_features
|
|
|
|
get root_url
|
|
|
|
assert_response :success
|
|
assert_select "#insights-feed", count: 0
|
|
assert_select "a[href=?]", insights_path, count: 0
|
|
end
|
|
|
|
private
|
|
def enable_preview_features
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
end
|
|
|
|
def disable_preview_features
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
end
|
|
end
|