Show verified personal run notifications in the existing inbox

This commit is contained in:
2026-10-24 23:01:30 +03:00
parent d2d6dad532
commit 6e37a4eedc
8 changed files with 529 additions and 9 deletions

View File

@@ -0,0 +1,81 @@
// #region ScenarioAnalytics.RunNotificationApi [C:3] [TYPE Module]
// @BRIEF Closed current personal run notification wire contract using the authenticated API wrapper.
// @INVARIANT Malformed response data never becomes a visible success or mismatch label.
import { api } from '$lib/api';
export type RunNotice = {
id: string; run_id: string; scenario_id: string; environment_id: string;
outcome: 'passed' | 'mismatch' | 'check_failed'; occurred_at: string; read: boolean;
};
export type RunNoticePage = { items: RunNotice[]; next_cursor: string | null };
// #region ScenarioAnalytics.RunNotificationApi.Object [C:2] [TYPE Function]
// @POST Only a non-null record with the exact requested key set survives.
function record(raw: unknown, keys: string[]): Record<string, unknown> {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('RUN_NOTICE_INVALID');
const result = raw as Record<string, unknown>;
if (Object.keys(result).length !== keys.length) throw new Error('RUN_NOTICE_INVALID');
for (const key of keys) if (!Object.hasOwn(result, key)) throw new Error('RUN_NOTICE_INVALID');
return result;
}
// #endregion ScenarioAnalytics.RunNotificationApi.Object
// #region ScenarioAnalytics.RunNotificationApi.Text [C:2] [TYPE Function]
// @POST An explicit nonempty string is returned without trimming, coercion or identity substitution.
function text(raw: unknown): string {
if (typeof raw !== 'string' || !raw || raw !== raw.trim()) throw new Error('RUN_NOTICE_INVALID');
return raw;
}
// #endregion ScenarioAnalytics.RunNotificationApi.Text
// #region ScenarioAnalytics.RunNotificationApi.Notice [C:3] [TYPE Function]
// @POST Closed outcome/date/read fields are validated before rendering or read-state replacement.
export function decodeRunNotice(raw: unknown): RunNotice {
const row = record(raw, ['id', 'run_id', 'scenario_id', 'environment_id', 'outcome', 'occurred_at', 'read']);
const outcome = row.outcome;
if (outcome !== 'passed' && outcome !== 'mismatch' && outcome !== 'check_failed') throw new Error('RUN_NOTICE_INVALID');
const occurredAt = text(row.occurred_at);
if (!Number.isFinite(Date.parse(occurredAt)) || typeof row.read !== 'boolean') throw new Error('RUN_NOTICE_INVALID');
if (!/(Z|\+00:00)$/.test(occurredAt)) throw new Error('RUN_NOTICE_INVALID');
if (new Date(occurredAt).toISOString().slice(0, 19) !== occurredAt.slice(0, 19)) throw new Error('RUN_NOTICE_INVALID');
return { id: text(row.id), run_id: text(row.run_id), scenario_id: text(row.scenario_id),
environment_id: text(row.environment_id), outcome, occurred_at: occurredAt, read: row.read };
}
// #endregion ScenarioAnalytics.RunNotificationApi.Notice
// #region ScenarioAnalytics.RunNotificationApi.Page [C:3] [TYPE Function]
// @POST A page contains only distinct complete notices and an explicit nullable continuation cursor.
export function decodeRunNoticePage(raw: unknown): RunNoticePage {
const page = record(raw, ['items', 'next_cursor']);
if (!Array.isArray(page.items) || page.items.length > 20) throw new Error('RUN_NOTICE_INVALID');
const items = page.items.map(decodeRunNotice);
const ids = new Set<string>();
for (const item of items) {
if (ids.has(item.id)) throw new Error('RUN_NOTICE_INVALID');
ids.add(item.id);
}
return { items, next_cursor: page.next_cursor === null ? null : text(page.next_cursor) };
}
// #endregion ScenarioAnalytics.RunNotificationApi.Page
// #region ScenarioAnalytics.RunNotificationApi.Load [C:2] [TYPE Function]
// @POST Fetch at most twenty recipient-scoped rows through the authenticated application API.
export async function loadRunNotices(cursor: string | null): Promise<RunNoticePage> {
const query = new URLSearchParams({ limit: '20' });
if (cursor !== null) query.set('cursor', cursor);
return decodeRunNoticePage(await api.fetchApi<unknown>(`/scenario-run-notifications?${query}`,
{ suppressToast: true, signal: AbortSignal.timeout(15000) }));
}
// #endregion ScenarioAnalytics.RunNotificationApi.Load
// #region ScenarioAnalytics.RunNotificationApi.Read [C:2] [TYPE Function]
// @POST Require the response to acknowledge exactly the requested receipt and read state.
export async function setRunNoticeRead(id: string, read: boolean): Promise<RunNotice> {
const row = decodeRunNotice(await api.requestApi<unknown>(
`/scenario-run-notifications/${encodeURIComponent(id)}/read`, 'PUT', { read },
{ suppressToast: true, signal: AbortSignal.timeout(15000) }));
if (row.id !== id || row.read !== read) throw new Error('RUN_NOTICE_ACK_CONFLICT');
return row;
}
// #endregion ScenarioAnalytics.RunNotificationApi.Read
// #endregion ScenarioAnalytics.RunNotificationApi

View File

@@ -0,0 +1,51 @@
<!-- #region ScenarioAnalytics.RunNotificationInbox [C:3] [TYPE Component] -->
<!-- @BRIEF Personal completed-check notifications link to the exact scenario run. -->
<!-- @RELATION BINDS_TO -> [ScenarioAnalytics.RunNotificationModel.State] -->
<!-- @UX_STATE loading -> progress; unavailable -> explicit retry; empty -> no notifications; ready -> typed result cards. -->
<!-- @UX_FEEDBACK Read state changes after acknowledgement, with buttons disabled during saving. -->
<!-- @UX_RECOVERY Retry clears stale rows and reads current authorized results. -->
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js';
import { Button, Card } from '$lib/ui';
import { ROUTES } from '$lib/routes';
import { RunNotificationModel } from '$lib/models/RunNotificationModel.svelte';
const model = new RunNotificationModel();
const dt = $derived($t.dashboard_testing);
// #region ScenarioAnalytics.RunNotificationInbox.Mount [C:1] [TYPE Function]
// @BRIEF Load this client-only inbox widget once when it becomes visible.
function mounted(): void { void model.reload(); }
// #endregion ScenarioAnalytics.RunNotificationInbox.Mount
onMount(mounted);
</script>
<section aria-label={dt.run_inbox_title} class="space-y-3" aria-busy={model.loading}>
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold">{dt.run_inbox_title}</h2>
<Button variant="secondary" size="sm" onclick={model.reload.bind(model)} disabled={model.loading || model.saving}>{dt.run_inbox_refresh}</Button>
</div>
{#if model.failed}
<p role="alert" class="text-destructive">{dt.run_inbox_unavailable}</p>
{:else if model.loading && !model.items.length}
<p aria-live="polite" class="text-text-muted">{dt.case_loading}</p>
{:else if !model.items.length}
<p class="rounded border border-dashed border-border p-5 text-text-muted">{dt.run_inbox_empty}</p>
{/if}
{#each model.items as item (item.id)}
<Card padding="sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<p class="font-medium" class:text-success={item.outcome === 'passed'} class:text-destructive={item.outcome === 'mismatch'} class:text-warning={item.outcome === 'check_failed'}>{dt[`run_inbox_${item.outcome}`]}</p>
<p class="text-sm text-text-muted">{new Date(item.occurred_at).toLocaleString()}</p>
<a class="text-primary hover:underline" href={ROUTES.dashboardTesting.runDetail(item.scenario_id, item.run_id)}>{dt.run_inbox_open_result}</a>
</div>
<Button variant="secondary" size="sm" disabled={model.saving || model.loading} onclick={model.toggle.bind(model, item)}>{item.read ? dt.investigation_mark_unread : dt.investigation_mark_read}</Button>
</div>
</Card>
{/each}
{#if model.nextCursor !== null}
<Button variant="secondary" onclick={model.more.bind(model)} disabled={model.loading || model.saving}>{dt.run_inbox_more}</Button>
{/if}
</section>
<!-- #endregion ScenarioAnalytics.RunNotificationInbox -->

View File

@@ -0,0 +1,67 @@
// #region Test.ScenarioAnalytics.RunNotificationInbox [C:3] [TYPE Module]
// @BRIEF Render actual localized inbox states and follow the exact retained run link without a browser lab.
// @RELATION VERIFIES -> [ScenarioAnalytics.RunNotificationInbox]
import { cleanup, fireEvent, render, screen } from '@testing-library/svelte';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { api } from '$lib/api';
import { locale } from '$lib/i18n/index.svelte.js';
import RunNotificationInbox from '../RunNotificationInbox.svelte';
const notice = { id: 'notice-1', run_id: 'run-1', scenario_id: 'scenario-1', environment_id: 'preprod',
outcome: 'mismatch', occurred_at: '2026-10-24T12:00:00+00:00', read: false };
// #region Test.ScenarioAnalytics.RunNotificationInbox.Language [C:1] [TYPE Function]
beforeEach(function useRussian() { locale.current = 'ru'; });
// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Language
// #region Test.ScenarioAnalytics.RunNotificationInbox.Cleanup [C:1] [TYPE Function]
afterEach(function reset() { cleanup(); vi.restoreAllMocks(); });
// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Cleanup
// #region Test.ScenarioAnalytics.RunNotificationInbox.Empty [C:2] [TYPE Function]
// @TEST_INVARIANT Empty success is rendered distinctly from an unavailable API.
it('renders the explicit empty state', async function rendersEmpty() {
vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [], next_cursor: null });
render(RunNotificationInbox);
expect(await screen.findByText('Уведомлений о результатах тестов пока нет.')).toBeTruthy();
expect(screen.queryByRole('alert')).toBeNull();
});
// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Empty
// #region Test.ScenarioAnalytics.RunNotificationInbox.Unavailable [C:2] [TYPE Function]
// @TEST_INVARIANT API failure shows localized recovery, never the raw backend error or a successful empty state.
it('shows recovery without raw error details', async function rendersUnavailable() {
vi.spyOn(api, 'fetchApi').mockRejectedValue(new Error('secret internal failure'));
render(RunNotificationInbox);
expect((await screen.findByRole('alert')).textContent).toContain('Уведомления недоступны.');
expect(screen.queryByText('secret internal failure')).toBeNull();
expect(screen.getByRole('button', { name: 'Обновить' })).toBeTruthy();
});
// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Unavailable
// #region Test.ScenarioAnalytics.RunNotificationInbox.Link [C:2] [TYPE Function]
// @TEST_INVARIANT A mismatch links to precisely the server-owned scenario and run, without starting a new run.
it('links the mismatch to its exact run and acknowledges read', async function rendersMismatch() {
vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null });
const put = vi.spyOn(api, 'requestApi').mockResolvedValue({ ...notice, read: true });
render(RunNotificationInbox);
expect(await screen.findByText('Обнаружено расхождение')).toBeTruthy();
expect(screen.getByRole('link', { name: 'Открыть результат' }).getAttribute('href')).toBe(
'/dashboard-testing/scenarios/scenario-1/runs/run-1');
const button = screen.getByRole('button', { name: /прочитан/i });
await fireEvent.click(button);
expect(put).toHaveBeenCalledWith('/scenario-run-notifications/notice-1/read', 'PUT', { read: true }, expect.objectContaining({ suppressToast: true }));
expect(await screen.findByRole('button', { name: 'Не прочитано' })).toBeTruthy();
});
// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Link
// #region Test.ScenarioAnalytics.RunNotificationInbox.CheckFailure [C:2] [TYPE Function]
// @TEST_INVARIANT An infrastructure/check failure never renders as a baseline mismatch.
it('distinguishes a check failure from mismatch', async function rendersCheckFailure() {
vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [{ ...notice, outcome: 'check_failed' }], next_cursor: null });
render(RunNotificationInbox);
expect(await screen.findByText('Проверка не завершена — результат требует внимания')).toBeTruthy();
expect(screen.queryByText('Обнаружено расхождение')).toBeNull();
});
// #endregion Test.ScenarioAnalytics.RunNotificationInbox.CheckFailure
// #endregion Test.ScenarioAnalytics.RunNotificationInbox

View File

@@ -1257,5 +1257,14 @@
"authoring_metric_error_DRAFT_METRIC_CAPTURE_INVALID": "Captured outcomes do not match the acknowledged selection. Approval is unavailable.",
"authoring_metric_restart": "Start again",
"authoring_metric_capture_recovery": "Start a new draft to inspect and select again. The selection in this draft is already locked.",
"authoring_metric_error_CAPACITY_UNAVAILABLE": "Baseline capture capacity is unavailable. Selection is acknowledged, but no baseline was captured."
"authoring_metric_error_CAPACITY_UNAVAILABLE": "Baseline capture capacity is unavailable. Selection is acknowledged, but no baseline was captured.",
"run_inbox_title": "Test results",
"run_inbox_refresh": "Refresh",
"run_inbox_unavailable": "Notifications are unavailable. Refresh the list to try again.",
"run_inbox_empty": "No test result notifications yet.",
"run_inbox_passed": "Test passed",
"run_inbox_mismatch": "Mismatch detected",
"run_inbox_check_failed": "Check incomplete — the result needs attention",
"run_inbox_open_result": "Open result",
"run_inbox_more": "Show more"
}

View File

@@ -1259,5 +1259,14 @@
"authoring_metric_error_DRAFT_METRIC_CAPTURE_INVALID": "Результат снятия эталона не соответствует подтверждённому выбору. Утверждение недоступно.",
"authoring_metric_restart": "Начать заново",
"authoring_metric_capture_recovery": "Для нового просмотра и выбора начните новый черновик. В этом черновике выбор уже зафиксирован.",
"authoring_metric_error_CAPACITY_UNAVAILABLE": "Сейчас нет ресурсов для снятия эталона. Выбор подтверждён, но эталон не снят."
"authoring_metric_error_CAPACITY_UNAVAILABLE": "Сейчас нет ресурсов для снятия эталона. Выбор подтверждён, но эталон не снят.",
"run_inbox_title": "Результаты тестов",
"run_inbox_refresh": "Обновить",
"run_inbox_unavailable": "Уведомления недоступны. Обновите список, чтобы повторить попытку.",
"run_inbox_empty": "Уведомлений о результатах тестов пока нет.",
"run_inbox_passed": "Тест пройден",
"run_inbox_mismatch": "Обнаружено расхождение",
"run_inbox_check_failed": "Проверка не завершена — результат требует внимания",
"run_inbox_open_result": "Открыть результат",
"run_inbox_more": "Показать ещё"
}

View File

@@ -0,0 +1,116 @@
// #region ScenarioAnalytics.RunNotificationModel [C:3] [TYPE Module]
// @BRIEF Personal test-result inbox state with bounded paging and generation-safe read updates.
// @RELATION DEPENDS_ON -> [ScenarioAnalytics.RunNotificationApi]
import { loadRunNotices, setRunNoticeRead, type RunNotice } from '$lib/api/run-notifications';
import { log } from '$lib/cot-logger';
// #region ScenarioAnalytics.RunNotificationModel.State [C:3] [TYPE Class]
// @INVARIANT Errors clear retained verdicts; a failed refresh is never represented as an empty successful inbox.
// @INVARIANT Only the newest request may publish rows; acknowledgement never optimistically changes read state.
export class RunNotificationModel {
items: RunNotice[] = $state([]);
nextCursor: string | null = $state(null);
loading = $state(false);
saving = $state(false);
failed = $state(false);
private generation = 0;
// #region ScenarioAnalytics.RunNotificationModel.Reload [C:2] [TYPE Function]
// @POST Clear potentially revoked receipts before requesting the current recipient page.
async reload(): Promise<void> {
this.items = [];
this.nextCursor = null;
await this.fetchPage(null);
}
// #endregion ScenarioAnalytics.RunNotificationModel.Reload
// #region ScenarioAnalytics.RunNotificationModel.More [C:2] [TYPE Function]
// @POST A continuation is requested only when the server supplied it and no operation is pending.
async more(): Promise<void> {
if (this.loading || this.saving || this.nextCursor === null) return;
await this.fetchPage(this.nextCursor);
}
// #endregion ScenarioAnalytics.RunNotificationModel.More
// #region ScenarioAnalytics.RunNotificationModel.Fetch [C:3] [TYPE Function]
// @POST A response from an obsolete load cannot replace newer rows or the newer loading state.
private async fetchPage(cursor: string | null): Promise<void> {
const generation = ++this.generation;
this.loading = true;
this.failed = false;
try {
const page = await loadRunNotices(cursor);
if (generation !== this.generation) return;
if (cursor !== null && cursor === page.next_cursor) throw new Error('RUN_NOTICE_CURSOR_CONFLICT');
this.items = cursor === null ? page.items : this.append(page.items);
this.nextCursor = page.next_cursor;
} catch {
if (generation === this.generation) this.unavailable();
} finally {
if (generation === this.generation) this.loading = false;
}
}
// #endregion ScenarioAnalytics.RunNotificationModel.Fetch
// #region ScenarioAnalytics.RunNotificationModel.Append [C:2] [TYPE Function]
// @POST Overlapping continuation pages refuse rather than hide duplicate or conflicting receipts.
private append(incoming: RunNotice[]): RunNotice[] {
const ids = new Set<string>();
for (const item of this.items) ids.add(item.id);
for (const item of incoming) if (ids.has(item.id)) throw new Error('RUN_NOTICE_PAGE_CONFLICT');
return [...this.items, ...incoming];
}
// #endregion ScenarioAnalytics.RunNotificationModel.Append
// #region ScenarioAnalytics.RunNotificationModel.Read [C:3] [TYPE Function]
// @POST Read state changes only from an exact current server acknowledgement; revoked access removes retained rows.
async toggle(item: RunNotice): Promise<void> {
if (this.saving || this.loading) return;
const generation = this.generation;
this.saving = true;
try {
const updated = await setRunNoticeRead(item.id, !item.read);
if (generation !== this.generation) return;
this.replace(updated);
} catch {
if (generation === this.generation) this.unavailable();
} finally {
this.saving = false;
}
}
// #endregion ScenarioAnalytics.RunNotificationModel.Read
// #region ScenarioAnalytics.RunNotificationModel.Replace [C:2] [TYPE Function]
// @POST Replace only the acknowledged row, preserving the server's order.
private replace(updated: RunNotice): void {
const rows = [];
for (const item of this.items) {
if (item.id === updated.id) this.requireSameOutcome(item, updated);
rows.push(item.id === updated.id ? updated : item);
}
this.items = rows;
}
// #endregion ScenarioAnalytics.RunNotificationModel.Replace
// #region ScenarioAnalytics.RunNotificationModel.SameOutcome [C:2] [TYPE Function]
// @POST A read-state acknowledgement cannot replace immutable result/navigation facts.
private requireSameOutcome(before: RunNotice, after: RunNotice): void {
for (const key of ['run_id', 'scenario_id', 'environment_id', 'outcome', 'occurred_at'] as const) {
if (before[key] !== after[key]) throw new Error('RUN_NOTICE_ACK_CONFLICT');
}
}
// #endregion ScenarioAnalytics.RunNotificationModel.SameOutcome
// #region ScenarioAnalytics.RunNotificationModel.Unavailable [C:2] [TYPE Function]
// @POST Show an explicit unavailable state without leaking raw backend errors or stale verdicts.
private unavailable(): void {
this.items = [];
this.nextCursor = null;
this.failed = true;
log('ScenarioAnalytics.RunNotificationModel', 'EXPLORE', 'Personal run inbox unavailable', {},
'Current authorized notifications could not be read', { error_code: 'RUN_INBOX_UNAVAILABLE' });
}
// #endregion ScenarioAnalytics.RunNotificationModel.Unavailable
}
// #endregion ScenarioAnalytics.RunNotificationModel.State
// #endregion ScenarioAnalytics.RunNotificationModel

View File

@@ -0,0 +1,127 @@
// #region Test.ScenarioAnalytics.RunNotificationModel [C:3] [TYPE Module]
// @BRIEF Current inbox rendering state never trusts malformed, stale or foreign acknowledgement data.
// @RELATION VERIFIES -> [ScenarioAnalytics.RunNotificationModel.State]
import { afterEach, expect, it, vi } from 'vitest';
import { api } from '$lib/api';
import { RunNotificationModel } from '../RunNotificationModel.svelte';
import { decodeRunNotice, decodeRunNoticePage } from '$lib/api/run-notifications';
const notice = { id: 'notice-1', run_id: 'run-1', scenario_id: 'scenario-1', environment_id: 'preprod',
outcome: 'passed', occurred_at: '2026-10-24T12:00:00+00:00', read: false };
// #region Test.ScenarioAnalytics.RunNotificationModel.Cleanup [C:1] [TYPE Function]
afterEach(function restoreMocks() { vi.restoreAllMocks(); });
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Cleanup
// #region Test.ScenarioAnalytics.RunNotificationModel.Load [C:2] [TYPE Function]
// @TEST_INVARIANT A complete current page exposes exact server facts and uses the bounded personal endpoint.
it('loads the bounded personal inbox', async function loadsCurrentPage() {
const fetch = vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null });
const model = new RunNotificationModel();
await model.reload();
expect(fetch).toHaveBeenCalledWith('/scenario-run-notifications?limit=20', expect.objectContaining({ suppressToast: true }));
expect(model.items).toEqual([notice]);
expect(model.failed).toBe(false);
expect(model.loading).toBe(false);
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Load
// #region Test.ScenarioAnalytics.RunNotificationModel.Read [C:2] [TYPE Function]
// @TEST_INVARIANT Server acknowledgement is required before read state changes.
it('uses the exact read acknowledgement', async function acknowledgesRead() {
vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null });
const put = vi.spyOn(api, 'requestApi').mockResolvedValue({ ...notice, read: true });
const model = new RunNotificationModel();
await model.reload();
await model.toggle(model.items[0]);
expect(put).toHaveBeenCalledWith('/scenario-run-notifications/notice-1/read', 'PUT', { read: true }, expect.objectContaining({ suppressToast: true }));
expect(model.items[0].read).toBe(true);
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Read
// #region Test.ScenarioAnalytics.RunNotificationModel.Revocation [C:2] [TYPE Function]
// @TEST_INVARIANT A revoked/unavailable acknowledgement clears old verdicts and offers explicit retry.
it('clears stale outcomes after access is revoked', async function clearsAfterDenial() {
vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null });
vi.spyOn(api, 'requestApi').mockRejectedValue(new Error('not accessible'));
const model = new RunNotificationModel();
await model.reload();
await model.toggle(model.items[0]);
expect(model.items).toEqual([]);
expect(model.failed).toBe(true);
expect(model.saving).toBe(false);
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Revocation
// #region Test.ScenarioAnalytics.RunNotificationModel.Conflict [C:2] [TYPE Function]
// @TEST_INVARIANT An acknowledgement with the same notice ID cannot swap its referenced scenario/run.
it('rejects a changed outcome in a read response', async function rejectsForeignAck() {
vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null });
vi.spyOn(api, 'requestApi').mockResolvedValue({ ...notice, read: true, run_id: 'foreign' });
const model = new RunNotificationModel();
await model.reload();
await model.toggle(model.items[0]);
expect(model.failed).toBe(true);
expect(model.items).toEqual([]);
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Conflict
// #region Test.ScenarioAnalytics.RunNotificationModel.DuplicatePage [C:2] [TYPE Function]
// @TEST_INVARIANT An overlapping next page cannot produce duplicated visible receipts.
it('rejects duplicate pages', async function rejectsDuplicatePage() {
vi.spyOn(api, 'fetchApi').mockResolvedValueOnce({ items: [notice], next_cursor: 'cursor-1' })
.mockResolvedValueOnce({ items: [notice], next_cursor: null });
const model = new RunNotificationModel();
await model.reload();
await model.more();
expect(model.failed).toBe(true);
expect(model.items).toEqual([]);
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.DuplicatePage
// #region Test.ScenarioAnalytics.RunNotificationModel.Deferred [C:2] [TYPE Function]
// @BRIEF Hold one API transport reply until the test explicitly releases it.
function deferredPage() {
let resolve!: (value: unknown) => void;
// #region Test.ScenarioAnalytics.RunNotificationModel.Deferred.Executor [C:1] [TYPE Function]
function retainResolver(accept: (value: unknown) => void): void { resolve = accept; }
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Deferred.Executor
const promise = new Promise<unknown>(retainResolver);
return { promise, resolve };
}
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Deferred
// #region Test.ScenarioAnalytics.RunNotificationModel.StaleLoad [C:2] [TYPE Function]
// @TEST_INVARIANT A late old response cannot restore rows cleared by a newer authoritative refresh.
it('ignores a stale load response', async function ignoresOlderPage() {
const pending = deferredPage();
vi.spyOn(api, 'fetchApi').mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce({ items: [], next_cursor: null });
const model = new RunNotificationModel();
const older = model.reload();
await model.reload();
pending.resolve({ items: [notice], next_cursor: null });
await older;
expect(model.items).toEqual([]);
expect(model.loading).toBe(false);
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.StaleLoad
// #region Test.ScenarioAnalytics.RunNotificationModel.Wire [C:2] [TYPE Function]
// @TEST_INVARIANT Unknown states, coerced booleans, implicit local time and missing facts refuse.
it.each([{ ...notice, outcome: 'success' }, { ...notice, read: 'false' },
{ ...notice, occurred_at: '2026-10-24T12:00:00' }, { ...notice, run_id: '' },
{ ...notice, occurred_at: '2026-02-31T12:00:00Z' },
{ ...notice, recipient_user_id: 'foreign' }])('refuses malformed wire data', function refusesWire(raw) {
expect(decodeRunNotice.bind(null, raw)).toThrow('RUN_NOTICE_INVALID');
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.Wire
// #region Test.ScenarioAnalytics.RunNotificationModel.PageWire [C:2] [TYPE Function]
// @TEST_INVARIANT Duplicate or partial pages cannot synthesize an empty successful response.
it.each([{ items: [notice, notice], next_cursor: null }, { items: [] }, null])(
'refuses a partial page', function refusesPage(raw) {
expect(decodeRunNoticePage.bind(null, raw)).toThrow('RUN_NOTICE_INVALID');
});
// #endregion Test.ScenarioAnalytics.RunNotificationModel.PageWire
// #endregion Test.ScenarioAnalytics.RunNotificationModel

View File

@@ -6,9 +6,10 @@
import {onMount} from 'svelte';
import {t} from '$lib/i18n/index.svelte.js';
import {ROUTES} from '$lib/routes';
import {Button,PageHeader,Input,Select} from '$lib/ui';
import {InvestigationSubscriptionModel,type TargetType} from '$lib/models/InvestigationSubscriptionModel.svelte';
import {Button,PageHeader,Input,Select,Card} from '$lib/ui';
import {InvestigationSubscriptionModel,type TargetType,type InboxNotification,type Subscription} from '$lib/models/InvestigationSubscriptionModel.svelte';
import InvestigationSubscriptionPanel from '$lib/components/scenario-analytics/InvestigationSubscriptionPanel.svelte';
import RunNotificationInbox from '$lib/components/scenario-analytics/RunNotificationInbox.svelte';
const model=new InvestigationSubscriptionModel();const dt=$derived($t.dashboard_testing??{});
let selectedTarget=$state('');
let targetType:TargetType=$state('scenario');let targetId=$state('');let activeTarget=$state('');let activeType:TargetType=$state('scenario');
@@ -16,16 +17,75 @@
async function refresh():Promise<void>{await Promise.all([model.inbox(),model.load()]);await model.resolveTargets();}
// #endregion ScenarioAnalytics.Route.Inbox.Refresh
// #region ScenarioAnalytics.Route.Inbox.TargetLabel [C:2] [TYPE Function] [SEMANTICS inbox,subscription,authorization]
function targetLabel(type:TargetType,id:string):string{return model.targets.find(target=>target.key===`${type}:${id}`)?.label||dt.investigation_target_unavailable;}
function targetLabel(type:TargetType,id:string):string{
for(const target of model.targets) if(target.key===`${type}:${id}` && target.label) return target.label;
return dt.investigation_target_unavailable;
}
// #endregion ScenarioAnalytics.Route.Inbox.TargetLabel
onMount(()=>{void refresh();});
const options=$derived.by(namedOptions);
const types=$derived.by(typeOptions);
const inherited=$derived.by(inheritedCoverage);
// #region ScenarioAnalytics.Route.Inbox.NamedOptions [C:2] [TYPE Function]
// @BRIEF Build options from existing authorized target labels only.
function namedOptions(){
const result=[{value:'',label:dt.investigation_select_target}];
for(const target of model.targets) result.push({value:target.key,label:target.label});
return result;
}
// #endregion ScenarioAnalytics.Route.Inbox.NamedOptions
// #region ScenarioAnalytics.Route.Inbox.TypeOptions [C:2] [TYPE Function]
// @BRIEF Localize the four supported explicit subscription scopes.
function typeOptions(){
const result=[];
for(const value of ['dashboard','scenario','environment','case']) result.push({value,label:dt[`investigation_${value}`]});
return result;
}
// #endregion ScenarioAnalytics.Route.Inbox.TypeOptions
// #region ScenarioAnalytics.Route.Inbox.InheritedCoverage [C:2] [TYPE Function]
// @BRIEF Show each target once when an actual subscription match is inherited.
function inheritedCoverage(){
const result=[];
for(const coverage of model.coverage){
for(const match of coverage.matches){
if(match.inherited){result.push(coverage);break;}
}
}
return result;
}
// #endregion ScenarioAnalytics.Route.Inbox.InheritedCoverage
// #region ScenarioAnalytics.Route.Inbox.SelectNamed [C:2] [TYPE Function]
// @POST Retain the exact selected target identity and type from the authorized list.
function selectNamedTarget():void{
for(const target of model.targets){
if(target.key===selectedTarget){activeTarget=target.id;activeType=target.type;return;}
}
}
// #endregion ScenarioAnalytics.Route.Inbox.SelectNamed
// #region ScenarioAnalytics.Route.Inbox.SelectManual [C:1] [TYPE Function]
// @BRIEF Forward the advanced target; the subscription API owns authorization.
function selectManualTarget():void{activeTarget=targetId.trim();activeType=targetType;}
// #endregion ScenarioAnalytics.Route.Inbox.SelectManual
// #region ScenarioAnalytics.Route.Inbox.Read [C:1] [TYPE Function]
// @BRIEF Delegate an existing investigation receipt read-state change.
function readNotification(notification:InboxNotification):void{void model.read(notification);}
// #endregion ScenarioAnalytics.Route.Inbox.Read
// #region ScenarioAnalytics.Route.Inbox.Remove [C:1] [TYPE Function]
// @BRIEF Remove only the selected explicit subscription.
function removeSubscription(subscription:Subscription):void{void model.remove(subscription);}
// #endregion ScenarioAnalytics.Route.Inbox.Remove
// #region ScenarioAnalytics.Route.Inbox.Mount [C:1] [TYPE Function]
// @BRIEF Preserve the existing client-side investigation page initialization.
function mounted():void{void refresh();}
// #endregion ScenarioAnalytics.Route.Inbox.Mount
onMount(mounted);
</script>
<svelte:head><title>{dt.investigation_notifications}</title></svelte:head>
<main class="mx-auto max-w-6xl space-y-5 p-6"><PageHeader title={dt.investigation_notifications}/>
<a class="text-primary hover:underline" href={ROUTES.dashboardTesting.analytics()}>{dt.case_page_back}</a>
{#if model.error}<p role="alert" class="text-destructive">{model.error}</p><Button onclick={()=>void refresh()}>{dt.retry}</Button>{/if}
<RunNotificationInbox />
{#if model.error}<p role="alert" class="text-destructive">{model.error}</p><Button onclick={refresh}>{dt.retry}</Button>{/if}
{#if model.loading}<p aria-live="polite">{dt.case_loading}</p>{:else if !model.notifications.length}<p class="rounded border border-dashed border-border p-5">{dt.investigation_notification_empty}</p>{/if}
{#each model.notifications as notification (notification.id)}<section class="rounded-lg border border-border bg-surface-card p-4"><div class="flex flex-wrap justify-between gap-3"><p class="font-medium">{dt[`investigation_${notification.kind}`] || notification.kind} · {new Date(notification.created_at).toLocaleString()}</p><Button variant="secondary" size="sm" disabled={model.saving} onclick={()=>void model.read(notification)}>{notification.read_at?dt.investigation_mark_unread:dt.investigation_mark_read}</Button></div>{#each notification.events as event (event.id)}<p class="mt-2 text-sm">{dt[`investigation_${event.category}`]} · {dt[`investigation_${event.kind}`] || event.kind}{#if typeof event.target_references?.case==='string'} · <a class="text-primary hover:underline" href={ROUTES.dashboardTesting.analyticsCase(event.target_references.case)}>{targetLabel('case',event.target_references.case)}</a>{/if}</p>{/each}</section>{/each}
<section class="rounded-lg border border-border bg-surface-card p-5"><h2 class="font-semibold">{dt.investigation_subscriptions}</h2>{#each model.subscriptions as subscription (subscription.id)}<div class="mt-3 flex flex-wrap justify-between gap-3"><p class="text-sm">{dt[`investigation_${subscription.target_type}`]} · {targetLabel(subscription.target_type,subscription.target_id)}</p><Button variant="link" size="sm" onclick={()=>void model.remove(subscription)} disabled={model.saving}>{dt.investigation_unsubscribe}</Button></div>{/each}{#each model.coverage.filter(coverage=>coverage.matches.some(match=>match.inherited)) as coverage (`${coverage.target_type}:${coverage.target_id}`)}<p class="mt-3 text-xs text-warning">{dt.investigation_inherited}: {targetLabel(coverage.target_type,coverage.target_id)}</p>{/each}<div class="mt-4 space-y-3"><Select label={dt.investigation_named_target} bind:value={selectedTarget} options={[{value:'',label:dt.investigation_select_target},...model.targets.map(target=>({value:target.key,label:target.label}))]}/><Button variant="secondary" disabled={!selectedTarget} onclick={()=>{const target=model.targets.find(item=>item.key===selectedTarget);if(target){activeTarget=target.id;activeType=target.type;}}}>{dt.investigation_manage_subscriptions}</Button></div><details class="mt-4"><summary class="cursor-pointer text-sm text-text-muted">{dt.investigation_advanced_target}</summary><p class="mt-2 text-xs text-text-muted">{dt.investigation_advanced_target_hint}</p><div class="mt-3 grid gap-3 sm:grid-cols-2"><Select label={dt.investigation_target_type} bind:value={targetType} options={['dashboard','scenario','environment','case'].map(value=>({value,label:dt[`investigation_${value}`]}))}/><Input label={dt.investigation_target_id} bind:value={targetId}/></div><Button class="mt-3" variant="secondary" disabled={!targetId.trim()} onclick={()=>{activeTarget=targetId.trim();activeType=targetType;}}>{dt.investigation_manage_subscriptions}</Button></details>{#if activeTarget}{#key `${activeType}:${activeTarget}`}<InvestigationSubscriptionPanel targetType={activeType} targetId={activeTarget}/>{/key}{/if}</section>
{#each model.notifications as notification (notification.id)}<Card padding="sm"><div class="flex flex-wrap justify-between gap-3"><p class="font-medium">{dt[`investigation_${notification.kind}`] || notification.kind} · {new Date(notification.created_at).toLocaleString()}</p><Button variant="secondary" size="sm" disabled={model.saving} onclick={readNotification.bind(null,notification)}>{notification.read_at?dt.investigation_mark_unread:dt.investigation_mark_read}</Button></div>{#each notification.events as event (event.id)}<p class="mt-2 text-sm">{dt[`investigation_${event.category}`]} · {dt[`investigation_${event.kind}`] || event.kind}{#if typeof event.target_references?.case==='string'} · <a class="text-primary hover:underline" href={ROUTES.dashboardTesting.analyticsCase(event.target_references.case)}>{targetLabel('case',event.target_references.case)}</a>{/if}</p>{/each}</Card>{/each}
<Card><h2 class="font-semibold">{dt.investigation_subscriptions}</h2>{#each model.subscriptions as subscription (subscription.id)}<div class="mt-3 flex flex-wrap justify-between gap-3"><p class="text-sm">{dt[`investigation_${subscription.target_type}`]} · {targetLabel(subscription.target_type,subscription.target_id)}</p><Button variant="link" size="sm" onclick={removeSubscription.bind(null,subscription)} disabled={model.saving}>{dt.investigation_unsubscribe}</Button></div>{/each}{#each inherited as coverage (`${coverage.target_type}:${coverage.target_id}`)}<p class="mt-3 text-xs text-warning">{dt.investigation_inherited}: {targetLabel(coverage.target_type,coverage.target_id)}</p>{/each}<div class="mt-4 space-y-3"><Select label={dt.investigation_named_target} bind:value={selectedTarget} options={options}/><Button variant="secondary" disabled={!selectedTarget} onclick={selectNamedTarget}>{dt.investigation_manage_subscriptions}</Button></div><details class="mt-4"><summary class="cursor-pointer text-sm text-text-muted">{dt.investigation_advanced_target}</summary><p class="mt-2 text-xs text-text-muted">{dt.investigation_advanced_target_hint}</p><div class="mt-3 grid gap-3 sm:grid-cols-2"><Select label={dt.investigation_target_type} bind:value={targetType} options={types}/><Input label={dt.investigation_target_id} bind:value={targetId}/></div><Button class="mt-3" variant="secondary" disabled={!targetId.trim()} onclick={selectManualTarget}>{dt.investigation_manage_subscriptions}</Button></details>{#if activeTarget}{#key `${activeType}:${activeTarget}`}<InvestigationSubscriptionPanel targetType={activeType} targetId={activeTarget}/>{/key}{/if}</Card>
</main>
<!-- #endregion ScenarioAnalytics.Route.Inbox -->