Keep popup instructions readable until continued
Show concise progress during first room create/join; reserve persistent recovery instructions for saved/uncertain attempts. Retain completed-action text until a local Continue button, without gating rules or AI. Preserve unread text across backgrounding, bounded summaries, mute and private-safe feedback. Fix the container QA helper path-import collision. Verified: typecheck, lint, 5200 offline tests, 52 HTTP tests and production build. Delayed/lost-response browser checks prove progress, refresh recovery and exact retry without duplicates. Notice stayed for over 47 seconds while the QA game progressed; Continue advances only presentation. Mobile 16px/44px controls verified. Existing games preserved. Container/deployed acceptance follows publication.
This commit is contained in:
1 parent
c2d392e155
commit
f29d05620c
10 files changed
+238
-115
No files matched your search
@@ -61,6 +61,10 @@ for saved retries and limits.
|
||||
|
||||
New create/join requests save a private, tab-scoped retry record before sending. If the response is uncertain, keep the tab open and use the explicit retry; refresh preserves the same request. Confirmed success removes that record. Clearly rejected first requests return to the form for correction. A retry can recover the original room and seat without creating duplicates, but closing the tab or clearing its data can lose the proof. This record is separate from the long-term seat recovery kit.
|
||||
|
||||
Normal creation/join shows brief progress; recovery instructions stay open when
|
||||
attention is needed. Completed-action notices wait for **Continue** so their text
|
||||
can be read at your pace. See [readable notices](docs/READABLE_NOTICES.md).
|
||||
|
||||
An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details.
|
||||
|
||||
Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits.
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function Home() {
|
||||
null,
|
||||
);
|
||||
const [entryProblem, setEntryProblem] = useState('');
|
||||
const [initialEntryPending, setInitialEntryPending] = useState(false);
|
||||
const [showEntryRecovery, setShowEntryRecovery] = useState(false);
|
||||
const [showHandover, setShowHandover] = useState(false);
|
||||
const [handoverPending, setHandoverPending] = useState(true);
|
||||
@@ -407,6 +408,7 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (firstDispatch) setInitialEntryPending(false);
|
||||
mutationPending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -443,6 +445,7 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
pendingEntry.current = attempt;
|
||||
setInitialEntryPending(true);
|
||||
setEntryAttempt(attempt);
|
||||
setAbandonAcknowledged(false);
|
||||
await dispatchEntry(attempt, true);
|
||||
@@ -808,6 +811,20 @@ export default function Home() {
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
// A first request is progress, not recovery. Keep the actionable recovery
|
||||
// instructions on screen only after uncertainty or a saved-request restore.
|
||||
if (initialEntryPending && entryAttempt)
|
||||
return (
|
||||
<main className="table-shell" aria-busy="true">
|
||||
<header className="masthead">
|
||||
<div className="wordmark">DUNE<span>ARRAKIS TABLE</span></div>
|
||||
</header>
|
||||
<section className="notice">
|
||||
<h1>{entryAttempt.kind === 'create' ? 'Creating your room…' : 'Joining the room…'}</h1>
|
||||
<output aria-live="polite">Please wait.</output>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
if (entryAttempt || entryProblem)
|
||||
return (
|
||||
<main className="table-shell" aria-busy={busy}>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useReducer,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { Bell, BellOff } from 'lucide-react';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { advanceActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { initialActionNotices, updateActionNotices, type AutomaticActionEvent, type QueuedActionNotice } from '@/lib/action-notice-queue';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export type { AutomaticActionEvent } from '@/lib/action-notice-queue';
|
||||
@@ -42,89 +40,28 @@ export function AutomaticActionNotice({
|
||||
readPreference,
|
||||
serverPreference,
|
||||
);
|
||||
// The first snapshot establishes a baseline. Refreshing never replays history.
|
||||
const seenSeq = useRef(
|
||||
events.reduce((last, event) => Math.max(last, event.seq), -1),
|
||||
);
|
||||
const queue = useRef<QueuedActionNotice[]>([]);
|
||||
const active = useRef<QueuedActionNotice | null>(null);
|
||||
const [notice, setNotice] = useState<
|
||||
(QueuedActionNotice & { fading: boolean }) | null
|
||||
>(null);
|
||||
const [completed, setCompleted] = useState(0);
|
||||
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const clearTimers = useCallback(() => {
|
||||
for (const timer of timers.current) clearTimeout(timer);
|
||||
timers.current = [];
|
||||
}, []);
|
||||
|
||||
const showNext = useCallback(() => {
|
||||
if (active.current) return;
|
||||
if (!readPreference() || document.visibilityState === 'hidden') {
|
||||
queue.current = [];
|
||||
return;
|
||||
}
|
||||
const next = queue.current.shift();
|
||||
if (!next) return;
|
||||
active.current = next;
|
||||
clearTimers();
|
||||
timers.current = [
|
||||
setTimeout(() => {
|
||||
setNotice({ ...next, fading: false });
|
||||
}, 0),
|
||||
setTimeout(() => {
|
||||
setNotice((current) =>
|
||||
current?.id === next.id ? { ...current, fading: true } : current,
|
||||
);
|
||||
}, 1250),
|
||||
setTimeout(() => {
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
setCompleted((count) => count + 1);
|
||||
}, 1500),
|
||||
];
|
||||
}, [clearTimers]);
|
||||
const [presentation, updatePresentation] = useReducer(updateActionNotices, events, initialActionNotices);
|
||||
|
||||
useEffect(() => {
|
||||
// Preserve the visible notice. Summarize older pending actions so a bot
|
||||
// batch cannot leave minutes of stale feedback; the chronicle keeps detail.
|
||||
const next = advanceActionNotices({ seenSeq: seenSeq.current, pending: queue.current }, events, {
|
||||
active: !!active.current,
|
||||
show: readPreference() && document.visibilityState !== 'hidden',
|
||||
updatePresentation({
|
||||
type: 'events', events, enabled,
|
||||
visible: document.visibilityState !== 'hidden',
|
||||
});
|
||||
seenSeq.current = next.seenSeq;
|
||||
queue.current = next.pending;
|
||||
showNext();
|
||||
}, [events, showNext, completed]);
|
||||
}, [events, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
updatePresentation({ type: 'hide' });
|
||||
};
|
||||
document.addEventListener('visibilitychange', hide);
|
||||
return () => document.removeEventListener('visibilitychange', hide);
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !enabled;
|
||||
memoryPreference = next;
|
||||
clearTimers();
|
||||
queue.current = [];
|
||||
active.current = null;
|
||||
setNotice(null);
|
||||
updatePresentation({ type: 'clear' });
|
||||
try {
|
||||
localStorage.setItem(PREFERENCE_KEY, String(next));
|
||||
memoryPreference = undefined;
|
||||
@@ -133,9 +70,6 @@ export function AutomaticActionNotice({
|
||||
}
|
||||
window.dispatchEvent(new Event(PREFERENCE_EVENT));
|
||||
};
|
||||
const house = notice
|
||||
? FACTIONS.find((candidate) => candidate.id === notice.faction)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -155,26 +89,30 @@ export function AutomaticActionNotice({
|
||||
aria-atomic="true"
|
||||
className="pointer-events-none fixed top-[max(1rem,env(safe-area-inset-top))] left-1/2 z-40 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2"
|
||||
>
|
||||
{enabled && notice && (
|
||||
<span
|
||||
className={`block rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl transition-opacity duration-250 motion-reduce:opacity-100 motion-reduce:transition-none ${notice.fading ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span
|
||||
className="block text-xs font-semibold tracking-wider uppercase"
|
||||
style={{ color: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-sm font-medium leading-5">
|
||||
{notice.name}
|
||||
</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
</span>
|
||||
{enabled && presentation.active && (
|
||||
<ActionNoticeCard notice={presentation.active} onContinue={() => updatePresentation({ type: 'continue' })} />
|
||||
)}
|
||||
</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The notice alone accepts input; it does not trap or move keyboard focus. */
|
||||
export function ActionNoticeCard({ notice, onContinue }: { notice: QueuedActionNotice; onContinue: () => void }) {
|
||||
const house = FACTIONS.find((candidate) => candidate.id === notice.faction);
|
||||
return <span
|
||||
className="pointer-events-auto block max-h-[calc(100dvh-2rem)] overflow-y-auto rounded-xl border border-[#807456] border-l-4 bg-[#141814]/95 px-4 py-3 text-[#f5ecda] shadow-xl"
|
||||
style={{ borderLeftColor: house?.color ?? '#d4b77b' }}
|
||||
>
|
||||
<span className="block text-sm font-semibold leading-5 tracking-wider uppercase" style={{ color: house?.color ?? '#d4b77b' }}>
|
||||
{house?.name ?? 'Automatic action'}
|
||||
</span>
|
||||
<span className="mt-1 block break-words text-base font-medium leading-6">{notice.name}</span>
|
||||
{notice.count > 1 && <span className="mt-1 block text-sm leading-5 text-[#d6cdb8]">
|
||||
{notice.count - 1} earlier {notice.count === 2 ? 'action' : 'actions'} also completed. Details are in the table chronicle.
|
||||
</span>}
|
||||
<Button type="button" variant="outline" className="mt-3 min-h-11 max-w-full whitespace-normal" style={{ fontSize: '1rem' }} onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</span>;
|
||||
}
|
||||
+23
-11
@@ -15,30 +15,41 @@ its separate mute and volume settings still apply.
|
||||
|
||||
## Bursts, refresh and controls
|
||||
|
||||
Each notice lasts 1.5 seconds and does not intercept pointer or keyboard input.
|
||||
Each notice stays visible until the player chooses **Continue** or switches
|
||||
notices off. Continue dismisses only this local message; it does not submit a
|
||||
game action, delay AI progress or pause a saved decision. Notices do not move
|
||||
keyboard focus or open a modal. Only the notice itself intercepts pointer input;
|
||||
the rest of the table remains usable. Regular text is at least 14 pixels, with
|
||||
a 44-pixel minimum button height and scrolling for a short viewport.
|
||||
|
||||
The queue holds at most three notices including the visible one. When more
|
||||
actions arrive, older pending notices merge into a count beside the most recent
|
||||
action in that group; their individual details remain in the table chronicle.
|
||||
A new burst never replaces the currently displayed notice. After arrivals stop,
|
||||
the visible notice and pending queue finish within 4.5 seconds, rather than
|
||||
retaining minutes of old events.
|
||||
A new burst never replaces the currently displayed notice. Continue displays
|
||||
the next queued notice, if any, until that notice is also dismissed.
|
||||
|
||||
Initial and restored snapshots establish a silent baseline. Already-seen events
|
||||
do not replay. Events received while notices are off or the page is hidden are
|
||||
consumed silently. Hiding the page clears any visible or queued notice. The
|
||||
consumed silently. Hiding the page clears pending notices while preserving the
|
||||
unread notice already displayed, so it is still there when the player returns. The
|
||||
**Automatic action notices** toggle remains cosmetic; it cannot submit a game
|
||||
action or change a saved decision. Reduced motion removes the fade.
|
||||
action or change a saved decision. Notices have no timed fade or dismissal.
|
||||
|
||||
## Verification boundary
|
||||
|
||||
`tests/action-notice-queue.test.tsx` checks small updates, a full 250-entry burst,
|
||||
successive bursts during an active notice, exact represented counts and the
|
||||
initial nonblocking output, plus stale/duplicate sequence handling and silent
|
||||
consumption without replay. `tests/completed-action-events.test.ts` exercises
|
||||
consumption without replay. The local presentation tests exercise Continue,
|
||||
unread text across bursts/backgrounding, mute and no replay; the rendered notice
|
||||
test checks readable content and its connected Continue control without focus
|
||||
or modal behavior. These tests do not mount a browser DOM.
|
||||
`tests/completed-action-events.test.ts` exercises
|
||||
committed results and pending/canceled paths. Private checkpoint evidence records
|
||||
the browser checks and final source-bound verification.
|
||||
|
||||
Browser QA used genuine Basic Atreides/Harkonnen/Emperor setup. Private batches
|
||||
Earlier browser QA, before the persistent Continue control, used genuine Basic
|
||||
Atreides/Harkonnen/Emperor setup. Private batches
|
||||
advanced only that QA room through real engine actions, preserving card/force
|
||||
custody and seat controllers. A seven-event burst displayed the Emperor's
|
||||
**Revival** with four earlier completed actions, then drained. The chronicle
|
||||
@@ -48,10 +59,11 @@ left it empty. Refresh retained the exact saved game row. At a 390-pixel viewpor
|
||||
the notice container was 343 pixels within a 375-pixel document without horizontal
|
||||
overflow; transient mobile text was not captured. The viewport was reset afterward.
|
||||
|
||||
The provider reports background tabs as visible, so an actual background-page
|
||||
That provider reported background tabs as visible, so an actual background-page
|
||||
transition was not exercised. The cursor tests and handler review cover that
|
||||
branch; the automated component test does not mount timers. Existing screenshot
|
||||
capture failures leave full visual acceptance open.
|
||||
branch. That earlier timed-notice evidence does not verify the persistent
|
||||
control; current browser acceptance belongs to the new checkpoint report.
|
||||
Existing screenshot capture failures leave full visual acceptance open.
|
||||
|
||||
This extends functional feedback; complete animation design, every successful
|
||||
power tag and full visual/device acceptance remain unfinished. It does not
|
||||
|
||||
@@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
The [readable-notice fix](READABLE_NOTICES.md) replaces the flashing recovery
|
||||
screen during ordinary room creation/join with concise progress. Actual recovery
|
||||
instructions stay until resolved, and completed-action text waits for a local
|
||||
Continue control. It does not add confirmation steps to game rules or AI turns.
|
||||
|
||||
Administrator access/directory checkpoint `c2d392e` passed types, lint, 5,196
|
||||
offline tests, 52 HTTP tests and build, plus focused authenticated/browser checks.
|
||||
Its production acceptance is being combined with this readability follow-up to
|
||||
avoid two deployments; saved games and full administration gates remain intact.
|
||||
|
||||
The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on
|
||||
22 September: secure admin access, room creation/removal and lifecycle controls,
|
||||
participant support, backup/restore, operational views and audited actions.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 22 September 2026 — Readable room entry and completed-action text
|
||||
|
||||
Normal create/join now shows concise pending text while the first request runs;
|
||||
the existing full recovery panel is reserved for saved or uncertain requests and
|
||||
remains until explicitly resolved. Automatic-action notices retain text behind a
|
||||
local Continue control without gating rules, server actions or AI pacing. See
|
||||
[readable notices](READABLE_NOTICES.md) and [action feedback](ACTION_FEEDBACK.md).
|
||||
Focused regressions, delayed/lost-response browser QA and required checkpoint
|
||||
checks accompany the change. Deployed evidence remains separate and required.
|
||||
|
||||
## 22 September 2026 — Administrator access and room directory prototype
|
||||
|
||||
The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Readable progress, recovery and action notices
|
||||
|
||||
The user reported that a popup after room creation disappeared before it could
|
||||
be read or acted on. The first create/join request was rendering the full saved
|
||||
request recovery panel while awaiting its initial response. Successful creation
|
||||
then removed it immediately, making actionable recovery instructions appear to
|
||||
flash on screen even when nothing had gone wrong.
|
||||
|
||||
First submissions now show only **Creating your room…** or **Joining the room…**
|
||||
and a short progress message. Successful responses still open the confirmed
|
||||
table. A lost/uncertain response, saved request after refresh, or storage problem
|
||||
shows the existing persistent recovery panel. Its exact retry, deliberate
|
||||
abandonment and saved-kit recovery controls remain available until the player
|
||||
resolves the request. The private retry proof is still saved before submission;
|
||||
there is no automatic retry or new seat operation.
|
||||
|
||||
Completed-action notices also retain their text until **Continue**, instead of
|
||||
vanishing after 1.5 seconds. This is a local display control: the server has
|
||||
already completed the action and human/AI play continues normally. New events
|
||||
cannot replace the text being read. The optional toggle, bounded summaries,
|
||||
private-information filtering and silent refresh baseline remain; see
|
||||
[completed-action feedback](ACTION_FEEDBACK.md).
|
||||
|
||||
The notice audit found no other timer removing substantive text in app/component
|
||||
flows. Errors and recovery prompts have explicit controls; component inspectors
|
||||
have close buttons. Network progress, polling deadlines and permission-expiry
|
||||
countdowns keep their existing behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Browser QA uses dedicated rooms and a temporary local transport that delays the
|
||||
first response and can lose one successful create response. This verifies actual
|
||||
rendered progress, durable recovery instructions, and explicit same-request retry
|
||||
without introducing production timing changes. Existing room-entry regressions
|
||||
cover retry identity, storage failure, ambiguous responses and no duplicate
|
||||
creates; notice regressions cover retention, continuation, summaries and mute.
|
||||
|
||||
Record browser, source-bound checks and deployed acceptance separately. Test
|
||||
normal create/join and notice controls on the deployed revision; a local delayed
|
||||
transport test does not establish production behavior. Preserve existing games.
|
||||
@@ -11,6 +11,36 @@ export type QueuedActionNotice = AutomaticActionEvent & { count: number };
|
||||
|
||||
export type ActionNoticeCursor = { seenSeq: number; pending: QueuedActionNotice[] };
|
||||
|
||||
export type ActionNoticePresentation = ActionNoticeCursor & { active: QueuedActionNotice | null };
|
||||
|
||||
type ActionNoticeUpdate =
|
||||
| { type: 'events'; events: readonly AutomaticActionEvent[]; enabled: boolean; visible: boolean }
|
||||
| { type: 'continue' }
|
||||
| { type: 'hide' }
|
||||
| { type: 'clear' };
|
||||
|
||||
/** Existing history establishes a silent baseline, including after refresh. */
|
||||
export function initialActionNotices(events: readonly AutomaticActionEvent[]): ActionNoticePresentation {
|
||||
return { seenSeq: events.reduce((last, event) => Math.max(last, event.seq), -1), pending: [], active: null };
|
||||
}
|
||||
|
||||
/** Local presentation only: reading or dismissing a notice never advances the game. */
|
||||
export function updateActionNotices(state: ActionNoticePresentation, update: ActionNoticeUpdate): ActionNoticePresentation {
|
||||
if (update.type === 'hide') return { ...state, pending: [] };
|
||||
if (update.type === 'clear') return { ...state, active: null, pending: [] };
|
||||
if (update.type === 'continue') return { ...state, active: state.pending[0] ?? null, pending: state.pending.slice(1) };
|
||||
|
||||
const active = update.enabled ? state.active : null;
|
||||
const cursor = advanceActionNotices(state, update.events, {
|
||||
active: !!active,
|
||||
show: update.enabled && update.visible,
|
||||
});
|
||||
// New updates never replace text that the player has not dismissed.
|
||||
return active
|
||||
? { ...cursor, active }
|
||||
: { ...cursor, active: cursor.pending[0] ?? null, pending: cursor.pending.slice(1) };
|
||||
}
|
||||
|
||||
/** Consume every observed sequence, even when presentation is disabled. */
|
||||
export function advanceActionNotices(
|
||||
cursor: ActionNoticeCursor,
|
||||
@@ -30,7 +60,7 @@ export function advanceActionNotices(
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep at most three 1.5-second notices, including the one already on screen. */
|
||||
/** Keep at most three notices, including the one awaiting local dismissal. */
|
||||
export function appendActionNotices(
|
||||
pending: readonly QueuedActionNotice[],
|
||||
incoming: readonly AutomaticActionEvent[],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AutomaticActionNotice } from '../components/automatic-action-notice';
|
||||
import { advanceActionNotices, appendActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue';
|
||||
import { ActionNoticeCard, AutomaticActionNotice } from '../components/automatic-action-notice';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { advanceActionNotices, appendActionNotices, initialActionNotices, updateActionNotices, type AutomaticActionEvent } from '../lib/action-notice-queue';
|
||||
|
||||
const events = (first: number, count: number): AutomaticActionEvent[] =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
@@ -17,7 +19,7 @@ void test('ordinary notices keep their public action and house without mutating
|
||||
assert.deepEqual(appendActionNotices([], [], false), []);
|
||||
});
|
||||
|
||||
void test('a full log batch becomes three brief notices while accounting for every action', () => {
|
||||
void test('a full log batch becomes three notices while accounting for every action', () => {
|
||||
const incoming = events(1, 250);
|
||||
const queue = appendActionNotices([], incoming, false);
|
||||
assert.equal(queue.length, 3);
|
||||
@@ -28,6 +30,66 @@ void test('a full log batch becomes three brief notices while accounting for eve
|
||||
assert.equal(queue[0].faction, incoming[247].faction);
|
||||
});
|
||||
|
||||
void test('unread action text survives new bursts and each Continue advances only the local queue', () => {
|
||||
const initial = initialActionNotices(events(1, 4));
|
||||
assert.deepEqual(initial, { seenSeq: 4, pending: [], active: null });
|
||||
const shown = updateActionNotices(initial, { type: 'events', events: events(1, 7), enabled: true, visible: true });
|
||||
assert.equal(shown.active?.seq, 5);
|
||||
const before = structuredClone(shown);
|
||||
const burst = updateActionNotices(shown, { type: 'events', events: events(8, 250), enabled: true, visible: true });
|
||||
assert.deepEqual(burst.active, shown.active);
|
||||
assert.equal(burst.pending.length, 2);
|
||||
assert.equal((burst.active?.count ?? 0) + burst.pending.reduce((sum, notice) => sum + notice.count, 0), 253);
|
||||
assert.deepEqual(shown, before);
|
||||
const first = updateActionNotices(burst, { type: 'continue' });
|
||||
assert.deepEqual(first.active, burst.pending[0]);
|
||||
assert.deepEqual(first.pending, burst.pending.slice(1));
|
||||
const second = updateActionNotices(first, { type: 'continue' });
|
||||
assert.deepEqual(second.active, burst.pending[1]);
|
||||
const finished = updateActionNotices(second, { type: 'continue' });
|
||||
assert.deepEqual(finished, { seenSeq: 257, pending: [], active: null });
|
||||
assert.deepEqual(updateActionNotices(finished, { type: 'events', events: events(1, 257), enabled: true, visible: true }), finished);
|
||||
});
|
||||
|
||||
void test('backgrounding retains the unread notice while discarding pending and hidden events without replay', () => {
|
||||
const shown = updateActionNotices(initialActionNotices([]), { type: 'events', events: events(1, 3), enabled: true, visible: true });
|
||||
const hidden = updateActionNotices(shown, { type: 'hide' });
|
||||
assert.deepEqual(hidden.active, shown.active);
|
||||
assert.deepEqual(hidden.pending, []);
|
||||
const updated = updateActionNotices(hidden, { type: 'events', events: events(1, 20), enabled: true, visible: false });
|
||||
assert.deepEqual(updated, { seenSeq: 20, pending: [], active: shown.active });
|
||||
const returned = updateActionNotices(updated, { type: 'events', events: events(1, 20), enabled: true, visible: true });
|
||||
assert.deepEqual(returned, updated);
|
||||
const dismissed = updateActionNotices(returned, { type: 'continue' });
|
||||
assert.deepEqual(dismissed, { seenSeq: 20, pending: [], active: null });
|
||||
});
|
||||
|
||||
void test('turning notices off clears unread content and consumes muted updates without replay', () => {
|
||||
const shown = updateActionNotices(initialActionNotices([]), { type: 'events', events: events(1, 3), enabled: true, visible: true });
|
||||
assert.deepEqual(updateActionNotices(shown, { type: 'clear' }), { seenSeq: 3, pending: [], active: null });
|
||||
const muted = updateActionNotices(shown, { type: 'events', events: events(1, 20), enabled: false, visible: true });
|
||||
assert.deepEqual(muted, { seenSeq: 20, pending: [], active: null });
|
||||
assert.deepEqual(updateActionNotices(muted, { type: 'events', events: events(1, 20), enabled: true, visible: true }), muted);
|
||||
});
|
||||
|
||||
void test('an action card exposes a readable local Continue button without modal or automatic focus behavior', () => {
|
||||
let continued = 0;
|
||||
const notice = { ...events(1, 1)[0], name: 'Shipment completed', count: 8 };
|
||||
const card = ActionNoticeCard({ notice, onContinue: () => { continued++; } });
|
||||
const markup = renderToStaticMarkup(card);
|
||||
assert.match(markup, /Atreides/);
|
||||
assert.match(markup, /Shipment completed/);
|
||||
assert.match(markup, /7 earlier actions.*table chronicle/);
|
||||
assert.match(markup, /pointer-events-auto/);
|
||||
assert.match(markup, /text-base/);
|
||||
assert.match(markup, /<button[^>]*type="button"[^>]*>Continue<\/button>/);
|
||||
assert.doesNotMatch(markup, /text-xs|autofocus|aria-modal|role="dialog"/);
|
||||
const control = Children.toArray(card.props.children).filter(isValidElement).find(child => child.type === Button);
|
||||
assert.ok(control);
|
||||
(control.props as { onClick: () => void }).onClick();
|
||||
assert.equal(continued, 1);
|
||||
});
|
||||
|
||||
void test('incoming bursts preserve the visible notice and merge existing summaries without losing counts', () => {
|
||||
const queue = appendActionNotices([], events(1, 12), false);
|
||||
const active = queue[0], before = structuredClone(queue);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join as joinPath } from 'node:path';
|
||||
|
||||
const image = process.argv[2];
|
||||
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
|
||||
@@ -13,7 +13,7 @@ const name = `dune-verify-${randomUUID()}`;
|
||||
const volume = `${name}-data`;
|
||||
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
||||
let base;
|
||||
const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-'));
|
||||
const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-'));
|
||||
let adminCookie;
|
||||
async function ready() {
|
||||
for (let attempt = 0; attempt < 90; attempt++) {
|
||||
@@ -36,12 +36,12 @@ try {
|
||||
docker('volume', 'create', volume);
|
||||
start();
|
||||
await ready();
|
||||
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', join(adminFiles, 'key')]);
|
||||
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', joinPath(adminFiles, 'key')]);
|
||||
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
|
||||
input: readFileSync(join(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
||||
input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
||||
});
|
||||
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
|
||||
const adminKey = readFileSync(join(adminFiles, 'key/access-key.txt'), 'utf8').trim();
|
||||
const adminKey = readFileSync(joinPath(adminFiles, 'key/access-key.txt'), 'utf8').trim();
|
||||
const adminLogin = await fetch(`${base}/api/admin/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
||||
body: JSON.stringify({ action: 'login', key: adminKey }),
|
||||
@@ -79,7 +79,7 @@ try {
|
||||
start();
|
||||
await ready();
|
||||
await restored();
|
||||
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', join(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', join(adminFiles, 'http')], { stdio: 'inherit' });
|
||||
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', joinPath(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', joinPath(adminFiles, 'http')], { stdio: 'inherit' });
|
||||
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
||||
execFileSync('npm', ['run', 'test:integration'], {
|
||||
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
||||
|
||||
Reference in new issue
Block a user