import React, { createContext, useContext, useEffect, useState } from 'react';
import { makeSeed, upgradeDemoState } from './seed.js';
const KEY = 'testcraft-studio-v2';
const LEGACY_KEY = 'ccil-studio-demo-v1';
const Store = createContext(null);
export const uid = prefix => `${prefix}-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
export const timestamp = () => new Date().toISOString();
export const contextKey = w => `${w.id}:${w.version}`;
export const roleLabels = { user: 'User', administrator: 'Administrator' };
export const canReview = s => ['user', 'administrator'].includes(s.session.role);
export const canApprove = s => s.session.authenticated === true && s.session.role === 'administrator';
export const visibleWorkspaces = s => s.workspaces.filter(w => w.team === s.session.team);
export function questionsFor(s, w) { return w ? s.questions.filter(q => q.workspaceId === w.id && q.version === w.version) : []; }
export function eligibleDocuments(s, w) {
if (!w) return [];
const baseline = s.baselines.find(b => b.id === w.baselineId);
const rules = new Map((baseline?.rules || []).map(r => [r.id, r]));
s.crs.filter(c => w.crIds.includes(c.id)).forEach(cr => cr.changes.forEach(change => { if (change.op === 'REMOVE') rules.delete(change.ruleId); else rules.set(change.ruleId, { source: change.source }); }));
(w.customRules || []).forEach(r => rules.set(r.id, r));
const currentSources = new Set([...rules.values()].map(r => r.source));
const ruleSources = new Set([...s.baselines.flatMap(b => b.rules.map(r => r.source)), ...s.crs.flatMap(cr => cr.changes.map(c => c.source))]);
return s.documents.filter(d => {
const member = (d.team === w.team && (baseline?.sourceIds.includes(d.id) || (w.sourceIds || []).includes(d.id) || d.workspaceId === w.id || w.crIds.includes(d.crId))) || ((w.sharedContractIds || []).includes(d.id) && s.shares.some(g => g.sourceId === d.id && g.consumer === w.team && g.status === 'Active'));
if (!member) return false;
if (d.crId && !w.crIds.includes(d.crId) && !baseline?.incorporatedCRs.includes(d.crId)) return false;
return !ruleSources.has(d.id) || currentSources.has(d.id);
});
}
export function resolveSelection(s, baselineId, crIds) {
const baseline = s.baselines.find(b => b.id === baselineId);
const issues = [];
if (!baseline || !['Active', 'Approved'].includes(baseline.status)) issues.push('Select an active System Baseline.');
if (baseline && baseline.team !== s.session.team) issues.push('The baseline must belong to the selected team.');
const changes = s.crs.filter(c => crIds.includes(c.id));
for (const cr of changes) {
if (cr.team !== s.session.team) issues.push(`${cr.ref} belongs to another team.`);
if (baseline?.incorporatedCRs.includes(cr.id)) issues.push(`${cr.ref} is already incorporated in ${baseline.ref}.`);
for (const required of cr.requires) if (!crIds.includes(required) && !baseline?.incorporatedCRs.includes(required)) issues.push(`${cr.ref} requires ${s.crs.find(c => c.id === required)?.ref || required}.`);
for (const conflict of cr.conflicts) if (crIds.includes(conflict)) issues.push(`${cr.ref} conflicts with ${s.crs.find(c => c.id === conflict)?.ref || conflict}.`);
}
return { baseline, changes, issues: [...new Set(issues)], diff: changes.flatMap(c => c.changes.map(change => ({ ...change, crRef: c.ref }))) };
}
export function readiness(s, w) {
if (!w) return { ready: false, blockers: ['Select a workspace.'], questions: [], sources: [], verified: 0 };
const questions = questionsFor(s, w);
const sources = eligibleDocuments(s, w);
const unresolved = questions.filter(q => q.status !== 'resolved');
const selection = resolveSelection(s, w.baselineId, w.crIds);
const blockers = [...selection.issues];
if (!w.requirementCount) blockers.push('No reviewed requirements are available.');
if (!sources.some(d => d.status === 'Ready' || d.status === 'Reviewed locally')) blockers.push('No reviewed evidence is available.');
if (sources.some(d => !['Ready', 'Reviewed locally'].includes(d.status))) blockers.push('Some sources still need local review.');
const missingShared = (w.sharedContractIds || []).filter(id => !sources.some(d => d.id === id));
if (missingShared.length) blockers.push('A selected shared contract is no longer accessible. Update the knowledge selection.');
const evidenceIds = new Set(sources.filter(d => ['Ready', 'Reviewed locally'].includes(d.status)).map(d => d.id));
const ruleMap = new Map((selection.baseline?.rules || []).map(r => [r.id, r]));
selection.diff.forEach(c => { if (c.op === 'REMOVE') ruleMap.delete(c.ruleId); else ruleMap.set(c.ruleId, { source: c.source }); });
(w.customRules || []).forEach(r => ruleMap.set(r.id, r));
if ([...ruleMap.values()].some(r => !evidenceIds.has(r.source))) blockers.push('A selected requirement is missing accessible reviewed evidence.');
return { ready: !blockers.length, blockers, questions, sources, verified: Math.max(0, w.requirementCount - new Set(unresolved.map(q => q.requirement)).size), selection };
}
export function workspaceStatus(s, w) {
if (!w.baselineId) return w.pendingBaselineId ? 'Awaiting baseline approval' : 'Add baseline';
if (!w.requirementCount) return 'Draft';
if (s.suites.some(t => t.workspaceId === w.id && t.version === w.version)) return 'Ready for review';
return 'Ready to generate';
}
export function bumpVersion(draft, w) {
const old = questionsFor(draft, w);
w.version += 1; w.updatedAt = timestamp();
old.forEach(q => draft.questions.push({ ...q, id: uid('question'), version: w.version, status: 'open', answer: '', sourceReference: '', answerSourceId: '', author: null, answeredAt: null, validatedBy: null, validatedAt: null }));
}
export function log(draft, action) { draft.audit.unshift({ id: uid('audit'), action, actor: draft.session.name, team: draft.session.team, at: timestamp() }); }
export function makeFreshState() {
const state = makeSeed();
for (const key of ['workspaces', 'documents', 'baselines', 'crs', 'questions', 'candidates', 'suites', 'jobs', 'shares', 'examples', 'audit']) state[key] = [];
state.threads = {}; state.activeWorkspaceId = ''; state.session.authenticated = false;
return state;
}
function load() {
try {
const stored = JSON.parse(localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY));
if (stored?.schema === 1) { stored.jobs = stored.jobs.map(j => ['running', 'queued'].includes(j.status) ? { ...j, status: 'Interrupted', stage: 'Browser restarted. Retry this run.' } : j); return upgradeDemoState(stored); }
} catch { /* Missing or invalid local data falls back to the sample dataset. */ }
return makeFreshState();
}
export function StoreProvider({ children }) {
const [state, setState] = useState(load);
const [saveError, setSaveError] = useState('');
const [toast, setToast] = useState('');
useEffect(() => { try { localStorage.setItem(KEY, JSON.stringify(state)); setSaveError(''); } catch { setSaveError('Browser storage is full or unavailable. Your latest changes are only in memory. Export your records from Administration.'); } }, [state]);
useEffect(() => { if (!toast) return; const timer = setTimeout(() => setToast(''), 4500); return () => clearTimeout(timer); }, [toast]);
const mutate = action => setState(previous => { const next = structuredClone(previous); action(next); return next; });
const workspace = visibleWorkspaces(state).find(w => w.id === state.activeWorkspaceId);
const reset = () => { setState(makeFreshState()); setToast('Application records reset.'); };
return <Store.Provider value={{ state, mutate, workspace, toast, notify: setToast, saveError, reset }}>{children}</Store.Provider>;
}
export const useStudio = () => useContext(Store);
export function download(name, content, type = 'text/plain') {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement('a'); a.href = url; a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function csv(rows) { return '\uFEFF' + rows.map(row => row.map(value => { let text = String(value ?? ''); if (/^[=+@\-\t\r]/.test(text)) text = `'${text}`; return `"${text.replaceAll('"', '""')}"`; }).join(',')).join('\r\n'); }
export function parseCsv(text) {
const rows = []; let row = [], cell = '', quoted = false;
const input = text.replace(/^\uFEFF/, '');
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch === '"') { if (quoted && input[i + 1] === '"') { cell += '"'; i++; } else quoted = !quoted; }
else if (ch === ',' && !quoted) { row.push(cell); cell = ''; }
else if (ch === '\n' && !quoted) { row.push(cell.replace(/\r$/, '')); rows.push(row); row = []; cell = ''; }
else cell += ch;
}
if (quoted) throw new Error('The CSV contains an unclosed quoted value.');
row.push(cell.replace(/\r$/, '')); if (row.some(Boolean)) rows.push(row);
return rows;
}
Top comments (0)