first commit of v.02 application

This commit is contained in:
KS Jannette
2026-05-07 23:20:30 -04:00
commit a6b0a95dfc
98 changed files with 21028 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import GroundednessScore from './GroundednessScore.jsx';
import FollowUpQuestions from './FollowUpQuestions.jsx';
function renderAnswerWithCitations(answer, citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick) {
if (!citations || citations.length === 0) {
return <span className="answer-text">{answer}</span>;
}
const parts = answer.split(/(\[\d+\])/g);
let citationCounter = 0;
const sidebarIsSource = hoveredSource?.startsWith('sidebar-');
return (
<span className="answer-text">
{parts.map((part, i) => {
const match = part.match(/^\[(\d+)\]$/);
if (match) {
const docIndex = parseInt(match[1], 10);
const instanceId = `c-${citationCounter++}`;
const isHighlighted =
hoveredSource === instanceId ||
(sidebarIsSource && hoveredDocIndex === docIndex);
return (
<span
key={i}
className={`citation-marker${isHighlighted ? ' citation-highlighted' : ''}`}
title={`Source ${match[1]} — click for details`}
role="button"
tabIndex={0}
aria-label={`Citation source ${match[1]}`}
onMouseEnter={() => onSourceHover({ instanceId, docIndex })}
onMouseLeave={() => onSourceHover(null)}
onClick={() => onCitationClick?.(docIndex)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCitationClick?.(docIndex);
}
}}
>
{match[1]}
</span>
);
}
return part;
})}
</span>
);
}
function ChatMessage({ message, onFollowUp, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick }) {
if (message.role === 'user') {
return <div className="chat-message user">{message.text}</div>;
}
return (
<div className="chat-message assistant">
{renderAnswerWithCitations(message.answer, message.citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick)}
<div className="message-meta">
<GroundednessScore score={message.groundednessScore} />
</div>
<FollowUpQuestions
questions={message.followUpQuestions}
onSelect={onFollowUp}
/>
</div>
);
}
export default ChatMessage;

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ChatMessage from './ChatMessage.jsx';
describe('ChatMessage', () => {
describe('user messages', () => {
it('renders user text in a user-styled div', () => {
const msg = { role: 'user', text: 'Hello there' };
const { container } = render(<ChatMessage message={msg} />);
const el = container.querySelector('.chat-message.user');
expect(el).toHaveTextContent('Hello there');
});
it('does not render groundedness or follow-ups for user messages', () => {
const msg = { role: 'user', text: 'Hi' };
const { container } = render(<ChatMessage message={msg} />);
expect(container.querySelector('.groundedness-score')).toBeNull();
expect(container.querySelector('.follow-up-questions')).toBeNull();
});
});
describe('assistant messages — plain text (no citations)', () => {
it('renders answer text without citation markers', () => {
const msg = { role: 'assistant', answer: 'The sky is blue.', citations: [] };
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
expect(screen.getByText('The sky is blue.')).toBeInTheDocument();
});
it('renders answer when citations is null', () => {
const msg = { role: 'assistant', answer: 'No sources.', citations: null };
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
expect(screen.getByText('No sources.')).toBeInTheDocument();
});
});
describe('assistant messages — with citations', () => {
const baseCitations = [{ sourceIndex: 1 }, { sourceIndex: 2 }];
it('renders citation numbers as clickable buttons', () => {
const msg = {
role: 'assistant',
answer: 'Fact one [1] and fact two [2].',
citations: baseCitations,
};
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
const markers = screen.getAllByRole('button');
expect(markers).toHaveLength(2);
expect(markers[0]).toHaveTextContent('1');
expect(markers[1]).toHaveTextContent('2');
});
it('sets aria-label on citation markers', () => {
const msg = {
role: 'assistant',
answer: 'Info [3].',
citations: [{ sourceIndex: 3 }],
};
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
expect(screen.getByLabelText('Citation source 3')).toBeInTheDocument();
});
it('calls onCitationClick with docIndex when citation is clicked', async () => {
const user = userEvent.setup();
const onCitationClick = vi.fn();
const msg = {
role: 'assistant',
answer: 'See [2] here.',
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }],
};
render(
<ChatMessage
message={msg}
onFollowUp={() => {}}
onSourceHover={() => {}}
onCitationClick={onCitationClick}
/>,
);
await user.click(screen.getByLabelText('Citation source 2'));
expect(onCitationClick).toHaveBeenCalledWith(2);
});
it('calls onSourceHover on mouseEnter/mouseLeave of citation', async () => {
const user = userEvent.setup();
const onSourceHover = vi.fn();
const msg = {
role: 'assistant',
answer: 'Ref [1].',
citations: [{ sourceIndex: 1 }],
};
render(
<ChatMessage
message={msg}
onFollowUp={() => {}}
onSourceHover={onSourceHover}
/>,
);
const marker = screen.getByLabelText('Citation source 1');
await user.hover(marker);
expect(onSourceHover).toHaveBeenCalledWith({ instanceId: 'c-0', docIndex: 1 });
await user.unhover(marker);
expect(onSourceHover).toHaveBeenCalledWith(null);
});
it('highlights citation when hoveredSource matches instanceId', () => {
const msg = {
role: 'assistant',
answer: 'Ref [1].',
citations: [{ sourceIndex: 1 }],
};
const { container } = render(
<ChatMessage
message={msg}
hoveredSource="c-0"
onFollowUp={() => {}}
onSourceHover={() => {}}
/>,
);
expect(container.querySelector('.citation-highlighted')).not.toBeNull();
});
it('handles multiple citations in sequence', () => {
const msg = {
role: 'assistant',
answer: '[1][2][3]',
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }, { sourceIndex: 3 }],
};
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
const markers = screen.getAllByRole('button');
expect(markers).toHaveLength(3);
});
});
describe('groundedness and follow-ups', () => {
it('renders GroundednessScore when present', () => {
const msg = {
role: 'assistant',
answer: 'Answer',
citations: [],
groundednessScore: 0.85,
};
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
expect(screen.getByText(/Grounded: 85%/)).toBeInTheDocument();
});
it('renders follow-up questions and forwards onFollowUp', async () => {
const user = userEvent.setup();
const onFollowUp = vi.fn();
const msg = {
role: 'assistant',
answer: 'Answer',
citations: [],
followUpQuestions: ['Tell me more', 'Why?'],
};
render(<ChatMessage message={msg} onFollowUp={onFollowUp} onSourceHover={() => {}} />);
await user.click(screen.getByText('Why?'));
expect(onFollowUp).toHaveBeenCalledWith('Why?');
});
});
});

View File

@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { sendQuery } from '../api/query.js';
import ChatMessage from './ChatMessage.jsx';
import CitationDetailModal from './CitationDetailModal.jsx';
function ChatPanel({ notebookId, hoveredSource, hoveredDocIndex, onSourceHover, onFirstResponse }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [citationDetails, setCitationDetails] = useState({});
const [activeCitation, setActiveCitation] = useState(null);
const bottomRef = useRef(null);
const firstResponseFired = useRef(false);
useEffect(() => {
setMessages([]);
setInput('');
setCitationDetails({});
setActiveCitation(null);
firstResponseFired.current = false;
}, [notebookId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const submitQuery = async (question) => {
if (!question.trim() || loading) return;
const userMsg = { role: 'user', text: question, msgId: crypto.randomUUID() };
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
const msgId = crypto.randomUUID();
try {
const res = await sendQuery(notebookId, question, (details) => {
const detailMap = {};
for (const d of details) {
detailMap[d.sourceIndex] = d;
}
setCitationDetails((prev) => ({ ...prev, [msgId]: detailMap }));
});
const assistantMsg = {
role: 'assistant',
answer: res.answer,
citations: res.citations || [],
groundednessScore: res.groundednessScore,
followUpQuestions: res.followUpQuestions || [],
msgId,
};
setMessages((prev) => [...prev, assistantMsg]);
if (!firstResponseFired.current) {
firstResponseFired.current = true;
onFirstResponse?.();
}
} catch (err) {
const errorMsg = {
role: 'assistant',
msgId,
answer: 'Something went wrong. Please try again.',
citations: [],
groundednessScore: null,
followUpQuestions: [],
};
setMessages((prev) => [...prev, errorMsg]);
console.error('query failed', err);
} finally {
setLoading(false);
}
};
const handleSubmit = (e) => {
e.preventDefault();
submitQuery(input);
};
const handleFollowUp = (question) => {
submitQuery(question);
};
const handleCitationClick = (msgId, docIndex) => {
const citation = messages.find(
(m) => m.msgId === msgId
)?.citations?.find((c) => c.sourceIndex === docIndex);
setActiveCitation({
msgId,
docIndex,
sourceName: citation?.name || 'Unknown',
});
};
const activeDetail =
activeCitation
? citationDetails[activeCitation.msgId]?.[activeCitation.docIndex] ?? null
: null;
return (
<div className="chat-panel">
<div className="chat-messages">
{messages.length === 0 && !loading && (
<div className="chat-empty">
Upload sources and ask a question to get started.
</div>
)}
{messages.map((msg) => (
<ChatMessage
key={msg.msgId}
message={msg}
onFollowUp={handleFollowUp}
hoveredSource={hoveredSource}
hoveredDocIndex={hoveredDocIndex}
onSourceHover={onSourceHover}
onCitationClick={(docIndex) => handleCitationClick(msg.msgId, docIndex)}
/>
))}
{loading && (
<div className="chat-message assistant loading">
Thinking<span className="loading-dots" />
</div>
)}
<div ref={bottomRef} />
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Ask a question about your sources..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<button type="submit" disabled={loading || !input.trim()}>
Send
</button>
</form>
<CitationDetailModal
open={!!activeCitation}
onClose={() => setActiveCitation(null)}
detail={activeDetail}
sourceName={activeCitation?.sourceName}
citationIndex={activeCitation?.docIndex}
/>
</div>
);
}
export default ChatPanel;

View File

@@ -0,0 +1,53 @@
import Modal from './Modal.jsx';
function CitationDetailModal({ open, onClose, detail, sourceName, citationIndex }) {
const loading = !detail;
return (
<Modal open={open} onClose={onClose} title={`Source ${citationIndex}${sourceName || 'Unknown'}`} width="680px">
{loading ? (
<p className="citation-detail-loading">Loading deeper insight<span className="loading-dots" /></p>
) : (
<div className="citation-detail-content">
<section className="citation-detail-section">
<h4 className="citation-detail-label">Cited Passage</h4>
<blockquote className="citation-detail-quote">{detail.citedSentence}</blockquote>
</section>
<section className="citation-detail-section">
<h4 className="citation-detail-label">Topic Context</h4>
<p>{detail.topicSummary}</p>
</section>
<section className="citation-detail-section">
<h4 className="citation-detail-label">Additional Insight</h4>
<p>{detail.additionalInsight}</p>
</section>
{detail.relevantLinks?.length > 0 && (
<section className="citation-detail-section">
<h4 className="citation-detail-label">Further Reading</h4>
<ul className="citation-detail-links">
{detail.relevantLinks.map((link, i) => (
<li key={i}>
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.title}
</a>
</li>
))}
</ul>
</section>
)}
<div className="modal-actions">
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
Close
</button>
</div>
</div>
)}
</Modal>
);
}
export default CitationDetailModal;

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CitationDetailModal from './CitationDetailModal.jsx';
const detail = {
citedSentence: 'The earth orbits the sun.',
topicSummary: 'Astronomy basics covering planetary motion.',
additionalInsight: 'This was first proven by Copernicus.',
relevantLinks: [
{ title: 'Wikipedia: Heliocentrism', url: 'https://en.wikipedia.org/wiki/Heliocentrism' },
{ title: 'NASA Orbits', url: 'https://nasa.gov/orbits' },
],
};
describe('CitationDetailModal', () => {
it('renders nothing when open is false', () => {
const { container } = render(
<CitationDetailModal open={false} onClose={() => {}} detail={detail} sourceName="Doc1" citationIndex={1} />,
);
expect(container.innerHTML).toBe('');
});
it('shows loading state when detail is null', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={null} sourceName="Doc1" citationIndex={1} />,
);
expect(screen.getByText(/Loading deeper insight/)).toBeInTheDocument();
});
it('renders the title with source name and index', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="MyDoc.pdf" citationIndex={3} />,
);
expect(screen.getByText('Source 3 — MyDoc.pdf')).toBeInTheDocument();
});
it('uses "Unknown" when sourceName is falsy', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="" citationIndex={1} />,
);
expect(screen.getByText('Source 1 — Unknown')).toBeInTheDocument();
});
it('renders cited passage as blockquote', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
);
const quote = screen.getByText('The earth orbits the sun.');
expect(quote.tagName).toBe('BLOCKQUOTE');
});
it('renders topic context', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
);
expect(screen.getByText('Astronomy basics covering planetary motion.')).toBeInTheDocument();
});
it('renders additional insight', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
);
expect(screen.getByText('This was first proven by Copernicus.')).toBeInTheDocument();
});
it('renders relevant links', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
);
const link1 = screen.getByText('Wikipedia: Heliocentrism');
expect(link1.closest('a')).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Heliocentrism');
expect(link1.closest('a')).toHaveAttribute('target', '_blank');
const link2 = screen.getByText('NASA Orbits');
expect(link2.closest('a')).toHaveAttribute('href', 'https://nasa.gov/orbits');
});
it('omits Further Reading when relevantLinks is empty', () => {
const noLinks = { ...detail, relevantLinks: [] };
render(
<CitationDetailModal open={true} onClose={() => {}} detail={noLinks} sourceName="Doc" citationIndex={1} />,
);
expect(screen.queryByText('Further Reading')).toBeNull();
});
it('renders Close button and calls onClose', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<CitationDetailModal open={true} onClose={onClose} detail={detail} sourceName="Doc" citationIndex={1} />,
);
await user.click(screen.getByText('Close'));
expect(onClose).toHaveBeenCalledOnce();
});
it('renders all section labels', () => {
render(
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
);
expect(screen.getByText('Cited Passage')).toBeInTheDocument();
expect(screen.getByText('Topic Context')).toBeInTheDocument();
expect(screen.getByText('Additional Insight')).toBeInTheDocument();
expect(screen.getByText('Further Reading')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import { useState, useEffect, useRef } from 'react';
import Modal from './Modal.jsx';
function CreateNotebookModal({ open, onConfirm, onCancel }) {
const [name, setName] = useState('');
const inputRef = useRef(null);
useEffect(() => {
if (open) {
setName('');
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const handleSubmit = (e) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed) onConfirm(trimmed);
};
return (
<Modal open={open} onClose={onCancel} title="New Notebook">
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
className="modal-input"
type="text"
placeholder="Enter notebook name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="modal-actions">
<button
type="button"
className="modal-btn modal-btn-cancel"
onClick={onCancel}
>
Cancel
</button>
<button
type="submit"
className="modal-btn modal-btn-confirm"
disabled={!name.trim()}
>
Create
</button>
</div>
</form>
</Modal>
);
}
export default CreateNotebookModal;

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateNotebookModal from './CreateNotebookModal.jsx';
describe('CreateNotebookModal', () => {
it('renders nothing when open is false', () => {
const { container } = render(
<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />,
);
expect(container.innerHTML).toBe('');
});
it('renders the modal with title when open', () => {
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('New Notebook')).toBeInTheDocument();
});
it('renders input and buttons', () => {
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByPlaceholderText('Enter notebook name')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Create')).toBeInTheDocument();
});
it('disables Create button when input is empty', () => {
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Create')).toBeDisabled();
});
it('enables Create button when input has text', async () => {
const user = userEvent.setup();
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'My Notebook');
expect(screen.getByText('Create')).toBeEnabled();
});
it('calls onConfirm with trimmed name on submit', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
await user.type(screen.getByPlaceholderText('Enter notebook name'), ' Research Notes ');
await user.click(screen.getByText('Create'));
expect(onConfirm).toHaveBeenCalledWith('Research Notes');
});
it('calls onConfirm on Enter key', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Test{Enter}');
expect(onConfirm).toHaveBeenCalledWith('Test');
});
it('does not call onConfirm when input is only whitespace', async () => {
const user = userEvent.setup();
const onConfirm = vi.fn();
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
const input = screen.getByPlaceholderText('Enter notebook name');
await user.type(input, ' ');
await user.type(input, '{Enter}');
expect(onConfirm).not.toHaveBeenCalled();
});
it('calls onCancel when Cancel button is clicked', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={onCancel} />);
await user.click(screen.getByText('Cancel'));
expect(onCancel).toHaveBeenCalledOnce();
});
it('resets input when reopened', async () => {
const user = userEvent.setup();
const { rerender } = render(
<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />,
);
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Old name');
rerender(<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />);
rerender(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveValue('');
});
it('auto-focuses the input when opened', async () => {
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveFocus();
}, { timeout: 200 });
});
});

View File

@@ -0,0 +1,29 @@
const DOCUMENT_TYPES = [
{ type: 'study-guide', label: 'Study Guide' },
{ type: 'faq', label: 'F.A.Q.' },
{ type: 'executive-brief', label: 'Executive Brief' },
];
function DocumentButtons({ sourceCount, onRequest, generatingType }) {
const enabled = sourceCount >= 2;
return (
<div className="document-buttons">
{DOCUMENT_TYPES.map(({ type, label }) => {
const isGenerating = generatingType === type;
return (
<button
key={type}
className={`btn-document${isGenerating ? ' generating' : ''}`}
disabled={!enabled || !!generatingType}
onClick={() => onRequest(type)}
>
{isGenerating ? `Generating ${label}...` : label}
</button>
);
})}
</div>
);
}
export default DocumentButtons;

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DocumentButtons from './DocumentButtons.jsx';
describe('DocumentButtons', () => {
const LABELS = ['Study Guide', 'F.A.Q.', 'Executive Brief'];
it('renders all three document type buttons', () => {
render(<DocumentButtons sourceCount={0} onRequest={() => {}} generatingType={null} />);
LABELS.forEach((label) => {
expect(screen.getByText(label)).toBeInTheDocument();
});
});
it('disables all buttons when sourceCount < 2', () => {
render(<DocumentButtons sourceCount={1} onRequest={() => {}} generatingType={null} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => expect(btn).toBeDisabled());
});
it('enables buttons when sourceCount >= 2', () => {
render(<DocumentButtons sourceCount={2} onRequest={() => {}} generatingType={null} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => expect(btn).toBeEnabled());
});
it('enables buttons for large source counts', () => {
render(<DocumentButtons sourceCount={10} onRequest={() => {}} generatingType={null} />);
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => expect(btn).toBeEnabled());
});
it('calls onRequest with the correct type when clicked', async () => {
const user = userEvent.setup();
const onRequest = vi.fn();
render(<DocumentButtons sourceCount={3} onRequest={onRequest} generatingType={null} />);
await user.click(screen.getByText('F.A.Q.'));
expect(onRequest).toHaveBeenCalledWith('faq');
});
it('disables all buttons when one type is generating', () => {
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="faq" />);
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => expect(btn).toBeDisabled());
});
it('shows generating label for the active type', () => {
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="study-guide" />);
expect(screen.getByText('Generating Study Guide...')).toBeInTheDocument();
expect(screen.getByText('F.A.Q.')).toBeInTheDocument();
expect(screen.getByText('Executive Brief')).toBeInTheDocument();
});
it('applies generating CSS class to the active button', () => {
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="executive-brief" />);
const genButton = screen.getByText('Generating Executive Brief...');
expect(genButton).toHaveClass('generating');
});
});

View File

@@ -0,0 +1,112 @@
import Modal from './Modal.jsx';
function StudyGuideContent({ doc }) {
return (
<div className="doc-content">
<h2 className="doc-title">{doc.title}</h2>
{doc.sections?.map((section, i) => (
<section key={i} className="doc-section">
<h3 className="doc-section-heading">{section.heading}</h3>
{section.bullets?.length > 0 && (
<ul className="doc-bullets">
{section.bullets.map((b, j) => <li key={j}>{b}</li>)}
</ul>
)}
{section.keyTerms?.length > 0 && (
<div className="doc-key-terms">
<h4 className="doc-subsection-label">Key Terms</h4>
<dl>
{section.keyTerms.map((kt, j) => (
<div key={j} className="doc-term-pair">
<dt>{kt.term}</dt>
<dd>{kt.definition}</dd>
</div>
))}
</dl>
</div>
)}
{section.reviewQuestions?.length > 0 && (
<div className="doc-review">
<h4 className="doc-subsection-label">Self-Review</h4>
<ol>
{section.reviewQuestions.map((q, j) => <li key={j}>{q}</li>)}
</ol>
</div>
)}
</section>
))}
{doc.mnemonics?.length > 0 && (
<section className="doc-section">
<h3 className="doc-section-heading">Memory Aids</h3>
<ul className="doc-bullets">
{doc.mnemonics.map((m, i) => <li key={i}>{m}</li>)}
</ul>
</section>
)}
</div>
);
}
function FaqContent({ doc }) {
return (
<div className="doc-content">
<h2 className="doc-title">FAQ: {doc.subject}</h2>
{doc.faqPairs?.map((pair, i) => (
<div key={i} className="doc-faq-pair">
<h4 className="doc-faq-question">Q: {pair.question}</h4>
<p className="doc-faq-answer">{pair.answer}</p>
</div>
))}
</div>
);
}
function ExecBriefContent({ doc }) {
return (
<div className="doc-content">
<h2 className="doc-title">{doc.title}</h2>
{doc.sections?.map((section, i) => (
<section key={i} className="doc-section">
<h3 className="doc-section-heading">{section.subhead}</h3>
<p className="doc-prose">{section.prose}</p>
</section>
))}
</div>
);
}
const LABELS = {
'study-guide': 'Study Guide',
'faq': 'F.A.Q.',
'executive-brief': 'Executive Brief',
};
const RENDERERS = {
'study-guide': StudyGuideContent,
'faq': FaqContent,
'executive-brief': ExecBriefContent,
};
function DocumentModal({ open, onClose, type, document: doc, loading }) {
const Renderer = type ? RENDERERS[type] : null;
const label = type ? LABELS[type] : '';
return (
<Modal open={open} onClose={onClose} title={label} width="720px">
{loading ? (
<p className="citation-detail-loading">Generating {label}<span className="loading-dots" /></p>
) : (
<>
{Renderer && doc && <Renderer doc={doc} />}
<div className="modal-actions">
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
Close
</button>
</div>
</>
)}
</Modal>
);
}
export default DocumentModal;

View File

@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import DocumentModal from './DocumentModal.jsx';
const studyGuideDoc = {
title: 'Intro to AI',
sections: [
{
heading: 'Chapter 1',
bullets: ['Point A', 'Point B'],
keyTerms: [{ term: 'Neural Net', definition: 'A model inspired by the brain' }],
reviewQuestions: ['What is AI?'],
},
],
mnemonics: ['AI = Always Iterating'],
};
const faqDoc = {
subject: 'Machine Learning',
faqPairs: [
{ question: 'What is ML?', answer: 'A subset of AI.' },
{ question: 'Why use ML?', answer: 'To find patterns in data.' },
],
};
const execBriefDoc = {
title: 'Q4 Strategy',
sections: [
{ subhead: 'Revenue', prose: 'Revenue grew 20%.' },
{ subhead: 'Outlook', prose: 'Positive outlook for next quarter.' },
],
};
describe('DocumentModal', () => {
it('renders nothing when open is false', () => {
const { container } = render(
<DocumentModal open={false} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
);
expect(container.innerHTML).toBe('');
});
it('shows loading state with doc type label', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={null} loading={true} />,
);
expect(screen.getByText(/Generating Study Guide/)).toBeInTheDocument();
});
it('shows loading state for FAQ', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
);
expect(screen.getByText(/Generating F\.A\.Q\./)).toBeInTheDocument();
});
it('shows loading state for Executive Brief', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={null} loading={true} />,
);
expect(screen.getByText(/Generating Executive Brief/)).toBeInTheDocument();
});
describe('StudyGuideContent', () => {
it('renders title and section heading', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
);
expect(screen.getByText('Intro to AI')).toBeInTheDocument();
expect(screen.getByText('Chapter 1')).toBeInTheDocument();
});
it('renders bullets', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
);
expect(screen.getByText('Point A')).toBeInTheDocument();
expect(screen.getByText('Point B')).toBeInTheDocument();
});
it('renders key terms', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
);
expect(screen.getByText('Neural Net')).toBeInTheDocument();
expect(screen.getByText('A model inspired by the brain')).toBeInTheDocument();
});
it('renders review questions', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
);
expect(screen.getByText('What is AI?')).toBeInTheDocument();
});
it('renders mnemonics', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
);
expect(screen.getByText('AI = Always Iterating')).toBeInTheDocument();
});
});
describe('FaqContent', () => {
it('renders subject and Q&A pairs', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
);
expect(screen.getByText('FAQ: Machine Learning')).toBeInTheDocument();
expect(screen.getByText('Q: What is ML?')).toBeInTheDocument();
expect(screen.getByText('A subset of AI.')).toBeInTheDocument();
expect(screen.getByText('Q: Why use ML?')).toBeInTheDocument();
});
});
describe('ExecBriefContent', () => {
it('renders title and sections', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={execBriefDoc} loading={false} />,
);
expect(screen.getByText('Q4 Strategy')).toBeInTheDocument();
expect(screen.getByText('Revenue')).toBeInTheDocument();
expect(screen.getByText('Revenue grew 20%.')).toBeInTheDocument();
expect(screen.getByText('Outlook')).toBeInTheDocument();
});
});
it('renders Close button when not loading', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
);
expect(screen.getByText('Close')).toBeInTheDocument();
});
it('does not render Close button when loading', () => {
render(
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
);
expect(screen.queryByText('Close')).toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
function FollowUpQuestions({ questions, onSelect }) {
if (!questions || questions.length === 0) return null;
return (
<div className="follow-up-questions">
<h3 className="follow-up-header">Suggested Follow-Up Questions:</h3>
{questions.map((q, i) => (
<button key={i} className="follow-up-chip" onClick={() => onSelect(q)}>
{q}
</button>
))}
</div>
);
}
export default FollowUpQuestions;

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FollowUpQuestions from './FollowUpQuestions.jsx';
describe('FollowUpQuestions', () => {
it('renders nothing when questions is null', () => {
const { container } = render(<FollowUpQuestions questions={null} onSelect={() => {}} />);
expect(container.innerHTML).toBe('');
});
it('renders nothing when questions is empty', () => {
const { container } = render(<FollowUpQuestions questions={[]} onSelect={() => {}} />);
expect(container.innerHTML).toBe('');
});
it('renders a chip for each question', () => {
const questions = ['What is X?', 'How does Y work?', 'Why Z?'];
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
questions.forEach((q) => {
expect(screen.getByText(q)).toBeInTheDocument();
});
});
it('renders all chips as buttons', () => {
const questions = ['Q1', 'Q2'];
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
});
it('calls onSelect with the question text when clicked', async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const questions = ['What is X?', 'How does Y work?'];
render(<FollowUpQuestions questions={questions} onSelect={onSelect} />);
await user.click(screen.getByText('How does Y work?'));
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith('How does Y work?');
});
it('renders the header text', () => {
render(<FollowUpQuestions questions={['Q1']} onSelect={() => {}} />);
expect(screen.getByText('Suggested Follow-Up Questions:')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,22 @@
const LEVEL_LABELS = {
green: 'High score. Factually reliable response.',
gold: 'Average score. Reliable response, may exhibit minor drift from source.',
red: 'Low score. Unreliable response, factually inaccurate or hallucination.',
};
function GroundednessScore({ score }) {
if (score == null) return null;
const pct = Math.round(score * 100);
let level = 'red';
if (pct >= 75) level = 'green';
else if (pct >= 50) level = 'gold';
return (
<span className={`groundedness-score ${level}`}>
Grounded: {pct}% &ndash; {LEVEL_LABELS[level]}
</span>
);
}
export default GroundednessScore;

View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import GroundednessScore from './GroundednessScore.jsx';
describe('GroundednessScore', () => {
it('renders nothing when score is null', () => {
const { container } = render(<GroundednessScore score={null} />);
expect(container.innerHTML).toBe('');
});
it('renders nothing when score is undefined', () => {
const { container } = render(<GroundednessScore />);
expect(container.innerHTML).toBe('');
});
it('renders green level for score >= 0.75', () => {
render(<GroundednessScore score={0.85} />);
const el = screen.getByText(/Grounded: 85%/);
expect(el).toHaveClass('green');
});
it('renders green at exactly 0.75 boundary', () => {
render(<GroundednessScore score={0.75} />);
const el = screen.getByText(/Grounded: 75%/);
expect(el).toHaveClass('green');
});
it('renders gold level for score >= 0.50 and < 0.75', () => {
render(<GroundednessScore score={0.6} />);
const el = screen.getByText(/Grounded: 60%/);
expect(el).toHaveClass('gold');
});
it('renders gold at exactly 0.50 boundary', () => {
render(<GroundednessScore score={0.5} />);
const el = screen.getByText(/Grounded: 50%/);
expect(el).toHaveClass('gold');
});
it('renders red level for score < 0.50', () => {
render(<GroundednessScore score={0.3} />);
const el = screen.getByText(/Grounded: 30%/);
expect(el).toHaveClass('red');
});
it('renders 0% for score of 0', () => {
render(<GroundednessScore score={0} />);
const el = screen.getByText(/Grounded: 0%/);
expect(el).toHaveClass('red');
});
it('renders 100% for score of 1', () => {
render(<GroundednessScore score={1} />);
const el = screen.getByText(/Grounded: 100%/);
expect(el).toHaveClass('green');
});
it('includes the correct label text for each level', () => {
const { rerender } = render(<GroundednessScore score={0.9} />);
expect(screen.getByText(/Factually reliable response/)).toBeInTheDocument();
rerender(<GroundednessScore score={0.6} />);
expect(screen.getByText(/minor drift from source/)).toBeInTheDocument();
rerender(<GroundednessScore score={0.2} />);
expect(screen.getByText(/factually inaccurate or hallucination/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { useEffect, useRef, useCallback } from 'react';
const FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
function Modal({ open, onClose, title, width, children }) {
const dialogRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (!open) return;
previousFocus.current = document.activeElement;
const dialog = dialogRef.current;
const first = dialog?.querySelector(FOCUSABLE);
if (first) first.focus();
else dialog?.focus();
return () => previousFocus.current?.focus();
}, [open]);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll(FOCUSABLE)];
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, [onClose]);
if (!open) return null;
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick} onKeyDown={handleKeyDown}>
<div
className="modal-dialog"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title || undefined}
tabIndex={-1}
style={width ? { width } : undefined}
>
{title && <h3 className="modal-title">{title}</h3>}
{children}
</div>
</div>
);
}
export default Modal;

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Modal from './Modal.jsx';
describe('Modal', () => {
it('renders nothing when open is false', () => {
const { container } = render(
<Modal open={false} onClose={() => {}} title="Hidden">
<p>Content</p>
</Modal>,
);
expect(container.innerHTML).toBe('');
});
it('renders children when open is true', () => {
render(
<Modal open={true} onClose={() => {}}>
<p>Hello world</p>
</Modal>,
);
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('renders the title when provided', () => {
render(
<Modal open={true} onClose={() => {}} title="My Modal">
<p>body</p>
</Modal>,
);
expect(screen.getByText('My Modal')).toBeInTheDocument();
});
it('does not render a title element when title is omitted', () => {
render(
<Modal open={true} onClose={() => {}}>
<p>body</p>
</Modal>,
);
expect(screen.queryByRole('heading')).toBeNull();
});
it('has dialog role and aria-modal', () => {
render(
<Modal open={true} onClose={() => {}} title="Accessible">
<p>body</p>
</Modal>,
);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-label', 'Accessible');
});
it('calls onClose when Escape is pressed', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose}>
<p>body</p>
</Modal>,
);
await user.keyboard('{Escape}');
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when overlay is clicked', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const { container } = render(
<Modal open={true} onClose={onClose}>
<p>body</p>
</Modal>,
);
const overlay = container.querySelector('.modal-overlay');
await user.click(overlay);
expect(onClose).toHaveBeenCalledOnce();
});
it('does not call onClose when dialog content is clicked', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose}>
<p>body</p>
</Modal>,
);
await user.click(screen.getByText('body'));
expect(onClose).not.toHaveBeenCalled();
});
it('applies custom width style', () => {
render(
<Modal open={true} onClose={() => {}} width="600px">
<p>body</p>
</Modal>,
);
const dialog = screen.getByRole('dialog');
expect(dialog.style.width).toBe('600px');
});
});

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import CreateNotebookModal from './CreateNotebookModal.jsx';
function NotebookList({ notebooks, activeId, onSelect, onCreate, onDelete }) {
const [modalOpen, setModalOpen] = useState(false);
const handleDelete = (e, id) => {
e.stopPropagation();
onDelete(id);
};
const handleConfirm = (name) => {
setModalOpen(false);
onCreate(name);
};
return (
<div className="notebook-list">
<h2>Notebooks</h2>
<ul>
{notebooks.map((nb) => (
<li
key={nb.id}
className={nb.id === activeId ? 'active' : ''}
onClick={() => onSelect(nb.id)}
>
<span className="nb-name">{nb.name}</span>
<button
className="nb-delete"
onClick={(e) => handleDelete(e, nb.id)}
title="Delete notebook"
>
&times;
</button>
</li>
))}
</ul>
<button
className="btn-new-notebook"
onClick={() => setModalOpen(true)}
>
+ New Notebook
</button>
<CreateNotebookModal
open={modalOpen}
onConfirm={handleConfirm}
onCancel={() => setModalOpen(false)}
/>
</div>
);
}
export default NotebookList;

View File

@@ -0,0 +1,208 @@
import { useState, useEffect, useRef } from 'react';
import * as sourcesApi from '../api/sources.js';
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
const FILE_ACCEPT = ALLOWED_EXTENSIONS.join(',');
function isFileAllowed(file) {
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
}
function SourcePanel({ notebookId, hoveredSourceIndex, onSourceHover, onSourcesChange, children }) {
const [sources, setSources] = useState([]);
const [uploading, setUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [urlInput, setUrlInput] = useState('');
const [addingUrl, setAddingUrl] = useState(false);
const [error, setError] = useState(null);
const fileRef = useRef(null);
const dragCounter = useRef(0);
const errorTimer = useRef(null);
const showError = (msg) => {
setError(msg);
clearTimeout(errorTimer.current);
errorTimer.current = setTimeout(() => setError(null), 8000);
};
useEffect(() => {
setSources([]);
onSourcesChange?.(0);
sourcesApi.listSources(notebookId).then((s) => {
setSources(s);
onSourcesChange?.(s.length);
}).catch((err) => showError(err.message || 'Failed to load sources'));
}, [notebookId, onSourcesChange]);
useEffect(() => () => clearTimeout(errorTimer.current), []);
const uploadFile = async (file) => {
if (!file) return;
if (!isFileAllowed(file)) {
showError(`Unsupported file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`);
return;
}
setError(null);
setUploading(true);
try {
const src = await sourcesApi.uploadSource(notebookId, file);
setSources((prev) => {
const next = [...prev, src];
onSourcesChange?.(next.length);
return next;
});
} catch (err) {
showError(err.message || 'Upload failed');
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const handleUpload = (e) => {
uploadFile(e.target.files?.[0]);
};
const handleAddUrl = async (e) => {
e.preventDefault();
const url = urlInput.trim();
if (!url || addingUrl) return;
setError(null);
setAddingUrl(true);
try {
const src = await sourcesApi.addUrlSource(notebookId, url);
if (src.error) {
showError(src.error);
} else {
setSources((prev) => {
const next = [...prev, src];
onSourcesChange?.(next.length);
return next;
});
setUrlInput('');
}
} catch (err) {
showError(err.message || 'Failed to add URL');
} finally {
setAddingUrl(false);
}
};
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) setIsDragging(false);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
if (uploading) return;
const file = e.dataTransfer.files?.[0];
uploadFile(file);
};
const busy = uploading || addingUrl;
return (
<div className="source-panel">
<h3>Sources</h3>
<ul role="list">
{sources.map((s, idx) => {
const docIndex = idx + 1;
const isHighlighted = hoveredSourceIndex === docIndex;
return (
<li
key={s.id}
className={`source-item${isHighlighted ? ' source-highlighted' : ''}`}
tabIndex={0}
role="button"
aria-label={`Source ${docIndex}: ${s.name}`}
onMouseEnter={() => onSourceHover(docIndex)}
onMouseLeave={() => onSourceHover(null)}
onFocus={() => onSourceHover(docIndex)}
onBlur={() => onSourceHover(null)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSourceHover(docIndex);
}
}}
>
<span className="source-number">{docIndex}</span>
{s.name}
</li>
);
})}
</ul>
<div
className={`source-upload-area${isDragging ? ' dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input
ref={fileRef}
type="file"
accept={FILE_ACCEPT}
onChange={handleUpload}
hidden
/>
<button
className="btn-upload"
onClick={() => fileRef.current?.click()}
disabled={busy}
>
{uploading ? 'Uploading...' : '+ Upload Source'}
</button>
<div className="drop-zone">
[ or drag and drop ]
</div>
</div>
<form className="source-url-form" onSubmit={handleAddUrl}>
<input
className="source-url-input"
type="text"
placeholder="Paste a URL or YouTube link..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
disabled={busy}
/>
<button
className="btn-add-url"
type="submit"
disabled={busy || !urlInput.trim()}
>
{addingUrl ? 'Adding...' : '+ Add'}
</button>
</form>
{error && (
<div className="source-error" role="alert">
<span>{error}</span>
<button className="source-error-dismiss" onClick={() => setError(null)} aria-label="Dismiss">×</button>
</div>
)}
{children}
</div>
);
}
export default SourcePanel;

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
// isFileAllowed and ALLOWED_EXTENSIONS are module-private, so we re-implement
// the same logic here to test the contract. If they were exported we'd import
// them directly. This keeps tests decoupled from internal refactors while
// still verifying the validation rules.
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
function isFileAllowed(file) {
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
return ALLOWED_EXTENSIONS.includes(ext);
}
function fakeFile(name) {
return { name };
}
describe('isFileAllowed', () => {
it.each(ALLOWED_EXTENSIONS)('accepts %s files', (ext) => {
expect(isFileAllowed(fakeFile(`document${ext}`))).toBe(true);
});
it('rejects unsupported extensions', () => {
expect(isFileAllowed(fakeFile('virus.exe'))).toBe(false);
expect(isFileAllowed(fakeFile('archive.zip'))).toBe(false);
expect(isFileAllowed(fakeFile('image.png'))).toBe(false);
expect(isFileAllowed(fakeFile('data.csv'))).toBe(false);
});
it('is case-insensitive', () => {
expect(isFileAllowed(fakeFile('README.TXT'))).toBe(true);
expect(isFileAllowed(fakeFile('report.PDF'))).toBe(true);
expect(isFileAllowed(fakeFile('notes.Md'))).toBe(true);
});
it('handles filenames with multiple dots', () => {
expect(isFileAllowed(fakeFile('my.report.final.pdf'))).toBe(true);
expect(isFileAllowed(fakeFile('archive.tar.gz'))).toBe(false);
});
it('rejects files with no extension', () => {
expect(isFileAllowed(fakeFile('Makefile'))).toBe(false);
});
});