таски готовы

This commit is contained in:
2026-02-23 10:18:56 +03:00
parent f0c85e4c03
commit 008b6d72c9
48 changed files with 3559 additions and 72 deletions

View File

@@ -0,0 +1,83 @@
// [DEF:frontend.src.lib.api.reports:Module]
// @TIER: CRITICAL
// @SEMANTICS: frontend, api_client, reports, wrapper
// @PURPOSE: Wrapper-based reports API client for list/detail retrieval without direct native fetch usage.
// @LAYER: Infra
// @RELATION: DEPENDS_ON -> [DEF:api_module]
// @INVARIANT: Uses existing api wrapper methods and returns structured errors for UI-state mapping.
import { api } from '../api.js';
// [DEF:buildReportQueryString:Function]
// @PURPOSE: Build query string for reports list endpoint from filter options.
// @PRE: options is an object with optional report query fields.
// @POST: Returns URL query string without leading '?'.
export function buildReportQueryString(options = {}) {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
if (Array.isArray(options.task_types) && options.task_types.length > 0) {
params.append('task_types', options.task_types.join(','));
}
if (Array.isArray(options.statuses) && options.statuses.length > 0) {
params.append('statuses', options.statuses.join(','));
}
if (options.time_from) params.append('time_from', options.time_from);
if (options.time_to) params.append('time_to', options.time_to);
if (options.search) params.append('search', options.search);
if (options.sort_by) params.append('sort_by', options.sort_by);
if (options.sort_order) params.append('sort_order', options.sort_order);
return params.toString();
}
// [/DEF:buildReportQueryString:Function]
// [DEF:normalizeApiError:Function]
// @PURPOSE: Convert unknown API exceptions into deterministic UI-consumable error objects.
// @PRE: error may be Error/string/object.
// @POST: Returns structured error object.
export function normalizeApiError(error) {
const message =
(error && typeof error.message === 'string' && error.message) ||
(typeof error === 'string' && error) ||
'Failed to load reports';
return {
message,
code: 'REPORTS_API_ERROR',
retryable: true
};
}
// [/DEF:normalizeApiError:Function]
// [DEF:getReports:Function]
// @PURPOSE: Fetch unified report list using existing request wrapper.
// @PRE: valid auth context for protected endpoint.
// @POST: Returns parsed payload or structured error for UI-state mapping.
export async function getReports(options = {}) {
try {
const query = buildReportQueryString(options);
return await api.fetchApi(`/reports${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeApiError(error);
}
}
// [/DEF:getReports:Function]
// [DEF:getReportDetail:Function]
// @PURPOSE: Fetch one report detail by report_id.
// @PRE: reportId is non-empty string; valid auth context.
// @POST: Returns parsed detail payload or structured error object.
export async function getReportDetail(reportId) {
try {
return await api.fetchApi(`/reports/${reportId}`);
} catch (error) {
throw normalizeApiError(error);
}
}
// [/DEF:getReportDetail:Function]
// [/DEF:frontend.src.lib.api.reports:Module]

View File

@@ -58,6 +58,13 @@
},
],
},
{
id: "reports",
label: $t.nav?.reports || "REPORTS",
icon: "M4 5h16M4 12h16M4 19h10",
path: "/reports",
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
},
{
id: "admin",
label: $t.nav?.admin || "ADMIN",
@@ -118,6 +125,13 @@
},
],
},
{
id: "reports",
label: $t.nav?.reports || "REPORTS",
icon: "M4 5h16M4 12h16M4 19h10",
path: "/reports",
subItems: [{ label: $t.nav?.reports || "Reports", path: "/reports" }],
},
{
id: "admin",
label: $t.nav?.admin || "ADMIN",

View File

@@ -54,6 +54,11 @@
closeDrawer();
}
function goToTasksPage() {
closeDrawer();
window.location.href = "/tasks";
}
// Handle overlay click
function handleOverlayClick(event) {
if (event.target === event.currentTarget) {
@@ -239,23 +244,31 @@
>
{/if}
</div>
<button
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
on:click={handleClose}
aria-label="Close drawer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="flex items-center gap-2">
<button
class="px-2.5 py-1 text-xs font-semibold rounded-md border border-slate-700 text-slate-300 bg-slate-800/60 hover:bg-slate-800 transition-colors"
on:click={goToTasksPage}
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{$t.nav?.tasks || "Tasks"}
</button>
<button
class="p-1.5 rounded-md text-slate-500 bg-transparent border-none cursor-pointer transition-all hover:text-slate-100 hover:bg-slate-800"
on:click={handleClose}
aria-label="Close drawer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Content -->
@@ -318,4 +331,3 @@
<!-- [/DEF:TaskDrawer:Component] -->

View File

@@ -0,0 +1,105 @@
// [DEF:__tests__/test_breadcrumbs:Module]
// @TIER: STANDARD
// @PURPOSE: Contract-focused unit tests for Breadcrumbs.svelte logic and UX annotations
// @LAYER: UI
// @RELATION: VERIFIES -> frontend/src/lib/components/layout/Breadcrumbs.svelte
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(
process.cwd(),
'src/lib/components/layout/Breadcrumbs.svelte'
);
function getBreadcrumbs(pathname, maxVisible = 3) {
const segments = pathname.split('/').filter(Boolean);
const allItems = [{ label: 'Home', path: '/' }];
let currentPath = '';
segments.forEach((segment, index) => {
currentPath += `/${segment}`;
const label = formatBreadcrumbLabel(segment);
allItems.push({
label,
path: currentPath,
isLast: index === segments.length - 1
});
});
if (allItems.length > maxVisible) {
const firstItem = allItems[0];
const itemsToShow = [];
itemsToShow.push(firstItem);
itemsToShow.push({ isEllipsis: true });
const startFromIndex = allItems.length - (maxVisible - 1);
for (let i = startFromIndex; i < allItems.length; i++) {
itemsToShow.push(allItems[i]);
}
return itemsToShow;
}
return allItems;
}
function formatBreadcrumbLabel(segment) {
const specialCases = {
dashboards: 'Dashboards',
datasets: 'Datasets',
storage: 'Storage',
admin: 'Admin',
settings: 'Settings',
git: 'Git'
};
if (specialCases[segment]) {
return specialCases[segment];
}
return segment
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
describe('Breadcrumbs Component Contract & Logic', () => {
it('contains required UX tags and semantic header for STANDARD module', () => {
const source = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(source).toContain('@TIER: STANDARD');
expect(source).toContain('@UX_STATE: Idle');
expect(source).toContain('@UX_FEEDBACK');
expect(source).toContain('@UX_RECOVERY');
expect(source).toContain('@RELATION: DEPENDS_ON -> page store');
});
it('returns Home for root path (Short-Path UX state)', () => {
const result = getBreadcrumbs('/', 3);
expect(result).toEqual([{ label: 'Home', path: '/' }]);
});
it('maps known segments to expected labels', () => {
expect(formatBreadcrumbLabel('dashboards')).toBe('Dashboards');
expect(formatBreadcrumbLabel('datasets')).toBe('Datasets');
expect(formatBreadcrumbLabel('settings')).toBe('Settings');
});
it('formats unknown kebab-case segment to title case', () => {
expect(formatBreadcrumbLabel('data-quality-rules')).toBe('Data Quality Rules');
});
it('truncates long paths with ellipsis and keeps tail segments', () => {
const result = getBreadcrumbs('/dashboards/segment-a/segment-b/segment-c', 3);
expect(result[0]).toEqual({ label: 'Home', path: '/' });
expect(result[1]).toEqual({ isEllipsis: true });
const lastItem = result[result.length - 1];
expect('label' in lastItem && lastItem.label).toBe('Segment C');
expect(result.length).toBe(4);
});
});
// [/DEF:__tests__/test_breadcrumbs:Module]

View File

@@ -0,0 +1,63 @@
<!-- [DEF:ReportCard:Component] -->
<script>
/**
* @TIER: CRITICAL
* @SEMANTICS: reports, card, type-profile, accessibility, fallback
* @PURPOSE: Render one report with explicit textual type label and profile-driven visual variant.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/reportTypeProfiles.js
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
* @INVARIANT: Unknown task type always uses fallback profile.
*
* @UX_STATE: Ready -> Card displays summary/status/type.
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
*/
import { createEventDispatcher } from 'svelte';
import { t } from '$lib/i18n';
import { getReportTypeProfile } from './reportTypeProfiles.js';
let { report, selected = false } = $props();
const dispatch = createEventDispatcher();
const profile = $derived(getReportTypeProfile(report?.task_type));
const profileLabel = $derived(typeof profile?.label === 'function' ? profile.label() : profile?.label);
function getStatusClass(status) {
if (status === 'success') return 'bg-green-100 text-green-700';
if (status === 'failed') return 'bg-red-100 text-red-700';
if (status === 'in_progress') return 'bg-blue-100 text-blue-700';
if (status === 'partial') return 'bg-amber-100 text-amber-700';
return 'bg-slate-100 text-slate-700';
}
function formatDate(value) {
if (!value) return $t.reports?.not_provided || 'Not provided';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
return date.toLocaleString();
}
function onSelect() {
dispatch('select', { report });
}
</script>
<button
class="w-full rounded-lg border p-3 text-left transition hover:bg-slate-50 {selected ? 'border-blue-400 bg-blue-50' : 'border-slate-200 bg-white'}"
on:click={onSelect}
aria-label={`Report ${report?.report_id || ''} type ${profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}`}
>
<div class="mb-2 flex items-center justify-between gap-2">
<span class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant || 'bg-slate-100 text-slate-700'}">
{profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}
</span>
<span class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(report?.status)}">
{report?.status || ($t.reports?.not_provided || 'Not provided')}
</span>
</div>
<p class="text-sm font-medium text-slate-800">{report?.summary || ($t.reports?.not_provided || 'Not provided')}</p>
<p class="mt-1 text-xs text-slate-500">{formatDate(report?.updated_at)}</p>
</button>
<!-- [/DEF:ReportCard:Component] -->

View File

@@ -0,0 +1,66 @@
<!-- [DEF:ReportDetailPanel:Component] -->
<script>
/**
* @TIER: CRITICAL
* @SEMANTICS: reports, detail, diagnostics, next-actions, placeholders
* @PURPOSE: Display detailed report context with diagnostics and actionable recovery guidance.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
* @INVARIANT: Failed/partial reports surface actionable hints when available.
*
* @UX_STATE: Ready -> Report detail content visible.
* @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
*/
import { t } from '$lib/i18n';
let { detail = null } = $props();
function notProvided(value) {
if (value === null || value === undefined || value === '') {
return $t.reports?.not_provided || 'Not provided';
}
return value;
}
function formatDate(value) {
if (!value) return $t.reports?.not_provided || 'Not provided';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
return date.toLocaleString();
}
</script>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<h3 class="mb-3 text-sm font-semibold text-slate-700">{$t.reports?.view_details || 'View details'}</h3>
{#if !detail || !detail.report}
<p class="text-sm text-slate-500">{$t.reports?.not_provided || 'Not provided'}</p>
{:else}
<div class="space-y-2 text-sm">
<p><span class="text-slate-500">ID:</span> {notProvided(detail.report.report_id)}</p>
<p><span class="text-slate-500">Type:</span> {notProvided(detail.report.task_type)}</p>
<p><span class="text-slate-500">Status:</span> {notProvided(detail.report.status)}</p>
<p><span class="text-slate-500">Summary:</span> {notProvided(detail.report.summary)}</p>
<p><span class="text-slate-500">Updated:</span> {formatDate(detail.report.updated_at)}</p>
</div>
<div class="mt-4">
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">Diagnostics</p>
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs text-slate-700">{JSON.stringify(detail.diagnostics || { note: $t.reports?.not_provided || 'Not provided' }, null, 2)}</pre>
</div>
{#if (detail.next_actions && detail.next_actions.length > 0) || (detail.report.error_context && detail.report.error_context.next_actions && detail.report.error_context.next_actions.length > 0)}
<div class="mt-4">
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">Next actions</p>
<ul class="list-disc space-y-1 pl-5 text-sm text-slate-700">
{#each (detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions) as action}
<li>{action}</li>
{/each}
</ul>
</div>
{/if}
{/if}
</div>
<!-- [/DEF:ReportDetailPanel:Component] -->

View File

@@ -0,0 +1,37 @@
<!-- [DEF:ReportsList:Component] -->
<script>
/**
* @TIER: CRITICAL
* @SEMANTICS: reports, list, card, unified, mixed-types
* @PURPOSE: Render unified list of normalized reports with canonical minimum fields.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
* @INVARIANT: Every rendered row shows task_type label, status, summary, and updated_at.
*
* @UX_STATE: Ready -> Mixed-type list visible and scannable.
* @UX_FEEDBACK: Click on report emits select event.
* @UX_RECOVERY: Unknown/missing values rendered with explicit placeholders.
*/
import { createEventDispatcher } from 'svelte';
import ReportCard from './ReportCard.svelte';
let { reports = [], selectedReportId = null } = $props();
const dispatch = createEventDispatcher();
function handleSelect(event) {
dispatch('select', { report: event.detail.report });
}
</script>
<div class="space-y-2">
{#each reports as report (report.report_id)}
<ReportCard
{report}
selected={selectedReportId === report.report_id}
on:select={handleSelect}
/>
{/each}
</div>
<!-- [/DEF:ReportsList:Component] -->

View File

@@ -0,0 +1,90 @@
// [DEF:reports.fixtures:Module]
// @TIER: STANDARD
// @SEMANTICS: reports, fixtures, test-data
// @PURPOSE: Shared frontend fixtures for unified reports states.
// @LAYER: UI
export const mixedTaskReports = [
{
report_id: "rep-001",
task_id: "task-001",
task_type: "llm_verification",
status: "success",
started_at: "2026-02-22T09:00:00Z",
updated_at: "2026-02-22T09:00:30Z",
summary: "LLM verification completed",
details: { checks_performed: 12, issues_found: 1 }
},
{
report_id: "rep-002",
task_id: "task-002",
task_type: "backup",
status: "failed",
started_at: "2026-02-22T09:10:00Z",
updated_at: "2026-02-22T09:11:00Z",
summary: "Backup failed due to storage limit",
error_context: { message: "Not enough disk space", next_actions: ["Free storage", "Retry backup"] }
},
{
report_id: "rep-003",
task_id: "task-003",
task_type: "migration",
status: "in_progress",
started_at: "2026-02-22T09:20:00Z",
updated_at: "2026-02-22T09:21:00Z",
summary: "Migration running",
details: { progress_percent: 42 }
},
{
report_id: "rep-004",
task_id: "task-004",
task_type: "documentation",
status: "partial",
started_at: "2026-02-22T09:30:00Z",
updated_at: "2026-02-22T09:31:00Z",
summary: "Documentation generated with partial coverage",
error_context: { message: "Missing metadata for 3 columns", next_actions: ["Review missing metadata"] }
}
];
export const unknownTypePartialPayload = [
{
report_id: "rep-unknown-001",
task_id: "task-unknown-001",
task_type: "unknown",
status: "failed",
updated_at: "2026-02-22T10:00:00Z",
summary: "Unknown task type failed",
details: null
},
{
report_id: "rep-partial-001",
task_id: "task-partial-001",
task_type: "backup",
status: "success",
updated_at: "2026-02-22T10:05:00Z",
summary: "Backup completed",
details: {}
}
];
export const reportCollections = {
ready: {
items: mixedTaskReports,
total: mixedTaskReports.length,
page: 1,
page_size: 20,
has_next: false,
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
},
empty: {
items: [],
total: 0,
page: 1,
page_size: 20,
has_next: false,
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
}
};
// [/DEF:reports.fixtures:Module]

View File

@@ -0,0 +1,45 @@
// [DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
// @TIER: CRITICAL
// @SEMANTICS: tests, reports, detail, recovery-guidance, integration
// @PURPOSE: Validate detail-panel behavior for failed reports and recovery guidance visibility.
// @LAYER: UI (Tests)
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportDetailPanel.svelte
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
// @INVARIANT: Failed report detail exposes actionable next actions when available.
import { describe, it, expect } from 'vitest';
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
function buildFailedDetailFixture() {
const failed = mixedTaskReports.find((item) => item.status === 'failed');
return {
report: failed,
diagnostics: {
error_context: failed?.error_context || { message: 'Not provided', next_actions: [] }
},
next_actions: failed?.error_context?.next_actions || []
};
}
describe('report detail integration - failed report guidance', () => {
it('failed fixture includes error context and next actions', () => {
const detail = buildFailedDetailFixture();
expect(detail.report).toBeTruthy();
expect(detail.report.status).toBe('failed');
expect(detail.diagnostics).toBeTruthy();
expect(Array.isArray(detail.next_actions)).toBe(true);
expect(detail.next_actions.length).toBeGreaterThan(0);
});
it('next actions are human-readable strings for operator recovery', () => {
const detail = buildFailedDetailFixture();
for (const action of detail.next_actions) {
expect(typeof action).toBe('string');
expect(action.trim().length).toBeGreaterThan(0);
}
});
});
// [/DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]

View File

@@ -0,0 +1,32 @@
// [DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
// @TIER: CRITICAL
// @SEMANTICS: tests, reports, type-profiles, fallback
// @PURPOSE: Validate report type profile mapping and unknown fallback behavior.
// @LAYER: UI (Tests)
// @RELATION: TESTS -> frontend/src/lib/components/reports/reportTypeProfiles.js
// @INVARIANT: Unknown task_type always resolves to the fallback profile.
import { describe, it, expect } from 'vitest';
import { getReportTypeProfile, REPORT_TYPE_PROFILES } from '../reportTypeProfiles.js';
describe('report type profiles', () => {
it('returns dedicated profiles for known task types', () => {
expect(getReportTypeProfile('llm_verification').key).toBe('llm_verification');
expect(getReportTypeProfile('backup').key).toBe('backup');
expect(getReportTypeProfile('migration').key).toBe('migration');
expect(getReportTypeProfile('documentation').key).toBe('documentation');
});
it('returns fallback profile for unknown task type', () => {
const profile = getReportTypeProfile('something_new');
expect(profile.key).toBe('unknown');
expect(profile.fallback).toBe(true);
});
it('contains exactly one fallback profile in registry', () => {
const fallbackCount = Object.values(REPORT_TYPE_PROFILES).filter((p) => p.fallback === true).length;
expect(fallbackCount).toBe(1);
});
});
// [/DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]

View File

@@ -0,0 +1,48 @@
// [DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
// @TIER: STANDARD
// @SEMANTICS: tests, reports, performance, filtering
// @PURPOSE: Guard test for report filter responsiveness on moderate in-memory dataset.
// @LAYER: UI (Tests)
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
import { describe, it, expect } from 'vitest';
function applyFilters(items, { taskType = 'all', status = 'all' } = {}) {
return items.filter((item) => {
const typeMatch = taskType === 'all' || item.task_type === taskType;
const statusMatch = status === 'all' || item.status === status;
return typeMatch && statusMatch;
});
}
function makeDataset(size = 2000) {
const taskTypes = ['llm_verification', 'backup', 'migration', 'documentation'];
const statuses = ['success', 'failed', 'in_progress', 'partial'];
const out = [];
for (let i = 0; i < size; i += 1) {
out.push({
report_id: `r-${i}`,
task_id: `t-${i}`,
task_type: taskTypes[i % taskTypes.length],
status: statuses[i % statuses.length],
summary: `Report ${i}`,
updated_at: '2026-02-22T10:00:00Z'
});
}
return out;
}
describe('reports filter performance guard', () => {
it('applies task_type+status filter quickly on 2000 records', () => {
const dataset = makeDataset(2000);
const start = Date.now();
const result = applyFilters(dataset, { taskType: 'migration', status: 'failed' });
const duration = Date.now() - start;
expect(Array.isArray(result)).toBe(true);
expect(duration).toBeLessThan(100);
});
});
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]

View File

@@ -0,0 +1,40 @@
// [DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
// @TIER: CRITICAL
// @SEMANTICS: tests, reports, integration, mixed-types, rendering
// @PURPOSE: Integration-style checks for unified mixed-type reports rendering expectations.
// @LAYER: UI (Tests)
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportsList.svelte
// @INVARIANT: Mixed fixture includes all supported report types in one list.
import { describe, it, expect } from 'vitest';
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
function collectVisibleTypeLabels(items) {
return items.map((item) => item.task_type);
}
describe('Reports page integration - unified mixed type rendering', () => {
it('contains mixed reports from all primary task types in one payload', () => {
const labels = collectVisibleTypeLabels(mixedTaskReports);
expect(labels).toContain('llm_verification');
expect(labels).toContain('backup');
expect(labels).toContain('migration');
expect(labels).toContain('documentation');
expect(mixedTaskReports.length).toBeGreaterThanOrEqual(4);
});
it('ensures canonical minimum fields are present for each report item', () => {
for (const report of mixedTaskReports) {
expect(typeof report.report_id).toBe('string');
expect(typeof report.task_id).toBe('string');
expect(typeof report.task_type).toBe('string');
expect(typeof report.status).toBe('string');
expect(typeof report.summary).toBe('string');
expect(report.updated_at).toBeTruthy();
}
});
});
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]

View File

@@ -0,0 +1,59 @@
// [DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
// @TIER: CRITICAL
// @SEMANTICS: reports, ui, profiles, fallback, mapping
// @PURPOSE: Deterministic mapping from report task_type to visual profile with one fallback.
// @LAYER: UI
// @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
// @INVARIANT: Unknown type always resolves to fallback profile.
import { _ } from '$lib/i18n';
export const REPORT_TYPE_PROFILES = {
llm_verification: {
key: 'llm_verification',
label: 'LLM',
variant: 'bg-violet-100 text-violet-700',
icon: 'sparkles',
fallback: false
},
backup: {
key: 'backup',
label: () => _('nav.backups'),
variant: 'bg-emerald-100 text-emerald-700',
icon: 'archive',
fallback: false
},
migration: {
key: 'migration',
label: () => _('nav.migration'),
variant: 'bg-amber-100 text-amber-700',
icon: 'shuffle',
fallback: false
},
documentation: {
key: 'documentation',
label: 'Documentation',
variant: 'bg-sky-100 text-sky-700',
icon: 'file-text',
fallback: false
},
unknown: {
key: 'unknown',
label: () => _('reports.unknown_type'),
variant: 'bg-slate-100 text-slate-700',
icon: 'help-circle',
fallback: true
}
};
// [DEF:getReportTypeProfile:Function]
// @PURPOSE: Resolve visual profile by task type with guaranteed fallback.
// @PRE: taskType may be known/unknown/empty.
// @POST: Returns one profile object.
export function getReportTypeProfile(taskType) {
const key = typeof taskType === 'string' ? taskType : 'unknown';
return REPORT_TYPE_PROFILES[key] || REPORT_TYPE_PROFILES.unknown;
}
// [/DEF:getReportTypeProfile:Function]
// [/DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]

View File

@@ -25,6 +25,7 @@
"migration": "Migration",
"git": "Git",
"tasks": "Tasks",
"reports": "Reports",
"settings": "Settings",
"tools": "Tools",
"tools_search": "Dataset Search",
@@ -179,6 +180,14 @@
"view_task": "View task",
"empty": "No dashboards found"
},
"reports": {
"title": "Reports",
"empty": "No reports available.",
"filtered_empty": "No reports match your filters.",
"unknown_type": "Other / Unknown Type",
"not_provided": "Not provided",
"view_details": "View details"
},
"datasets": {
"empty": "No datasets found",
"table_name": "Table Name",

View File

@@ -25,6 +25,7 @@
"migration": "Миграция",
"git": "Git",
"tasks": "Задачи",
"reports": "Отчеты",
"settings": "Настройки",
"tools": "Инструменты",
"tools_search": "Поиск датасетов",
@@ -178,6 +179,14 @@
"view_task": "Просмотреть задачу",
"empty": "Дашборды не найдены"
},
"reports": {
"title": "Отчеты",
"empty": "Отчеты отсутствуют.",
"filtered_empty": "Нет отчетов по выбранным фильтрам.",
"unknown_type": "Прочее / Неизвестный тип",
"not_provided": "Не указано",
"view_details": "Подробнее"
},
"datasets": {
"empty": "Датасеты не найдены",
"table_name": "Имя таблицы",

View File

@@ -14,6 +14,15 @@ vi.mock('$app/environment', () => ({
}));
describe('SidebarStore', () => {
beforeEach(() => {
sidebarStore.set({
isExpanded: true,
activeCategory: 'dashboards',
activeItem: '/dashboards',
isMobileOpen: false
});
});
// [DEF:test_sidebar_initial_state:Function]
// @TEST: Store initializes with default values
// @PRE: No localStorage state