901fd659df
- Add NextGraph data layer with @ng-org/orm, SHEX shapes (Event, UserProfile, Participation), session management, and FestipodDataContext with dual-mode operation (connected via NextGraph or local seed data) - Add BrokerBanner and NgStatus components showing connection status - Refactor to feature-based architecture: organize code by business domain (event, user, home, auth, workshop, meeting, notification) instead of technical layer. Modules only import from shared/, never from each other - Collocate BDD features and step definitions with their modules: event-specific steps in event/steps/, user steps in user/steps/, shared generic steps remain in shared/steps/ - Set up multi-layer BDD structure (frontend/backend/e2e steps per module) - Add project documentation (AGENTS.md, .project/knowledge/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
import { Glob } from 'bun';
|
|
import type { ParsedFeature, ParsedScenario, ParsedStep } from '../src/shared/types/gherkin';
|
|
|
|
// Map French screen names to screen IDs (same as navigation.steps.ts)
|
|
const screenNameMap: Record<string, string> = {
|
|
'accueil': 'home',
|
|
'liste des événements': 'events',
|
|
'découvrir': 'events',
|
|
'détail événement': 'event-detail',
|
|
'détail de l\'événement': 'event-detail',
|
|
'créer un événement': 'create-event',
|
|
'création d\'événement': 'create-event',
|
|
'inviter des amis': 'invite',
|
|
'invitation': 'invite',
|
|
'mon profil': 'profile',
|
|
'profil': 'profile',
|
|
'profil utilisateur': 'user-profile',
|
|
'profil d\'un utilisateur': 'user-profile',
|
|
'connexion': 'login',
|
|
'paramètres': 'settings',
|
|
'réglages': 'settings',
|
|
'points de rencontre': 'meeting-points',
|
|
'partage de profil': 'share-profile',
|
|
'mon réseau': 'friends-list',
|
|
'liste des participants': 'participants-list',
|
|
};
|
|
|
|
// Valid screen IDs (for direct matches)
|
|
const validScreenIds = new Set([
|
|
'home', 'events', 'event-detail', 'create-event', 'invite',
|
|
'profile', 'user-profile', 'login', 'settings',
|
|
'meeting-points', 'share-profile', 'friends-list', 'participants-list',
|
|
]);
|
|
|
|
function resolveScreenId(name: string): string | null {
|
|
const normalized = name.toLowerCase().trim();
|
|
// First check if it's already a valid screen ID
|
|
if (validScreenIds.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
// Then check the French name map
|
|
return screenNameMap[normalized] || null;
|
|
}
|
|
|
|
function extractScreenIdsFromSteps(steps: ParsedStep[]): Set<string> {
|
|
const screenIds = new Set<string>();
|
|
|
|
for (const step of steps) {
|
|
// Match patterns like: je vois l'écran "event-detail", je suis sur la page "accueil", etc.
|
|
const patterns = [
|
|
/je vois l'écran "([^"]+)"/gi,
|
|
/je suis sur la page "([^"]+)"/gi,
|
|
/je suis redirigé vers "([^"]+)"/gi,
|
|
/je navigue vers "([^"]+)"/gi,
|
|
/l'écran "([^"]+)" est affiché/gi,
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
let match;
|
|
while ((match = pattern.exec(step.text)) !== null) {
|
|
const captured = match[1];
|
|
if (captured) {
|
|
const screenId = resolveScreenId(captured);
|
|
if (screenId) {
|
|
screenIds.add(screenId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return screenIds;
|
|
}
|
|
|
|
async function parseFeatures(): Promise<ParsedFeature[]> {
|
|
const glob = new Glob('src/modules/*/features/**/*.feature');
|
|
const features: ParsedFeature[] = [];
|
|
|
|
for await (const filePath of glob.scan('.')) {
|
|
const content = await Bun.file(filePath).text();
|
|
const parsed = parseGherkinContent(content, filePath);
|
|
if (parsed) {
|
|
features.push(parsed);
|
|
}
|
|
}
|
|
|
|
features.sort((a, b) => {
|
|
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
return a.category.localeCompare(b.category);
|
|
});
|
|
|
|
const output = `// Auto-generated by scripts/parse-features.ts
|
|
// Do not edit manually
|
|
import type { ParsedFeature } from '../types/gherkin';
|
|
|
|
export const parsedFeatures: ParsedFeature[] = ${JSON.stringify(features, null, 2)};
|
|
|
|
export function getFeatureById(id: string): ParsedFeature | undefined {
|
|
return parsedFeatures.find(f => f.id === id);
|
|
}
|
|
|
|
export function getFeaturesByCategory(category: string): ParsedFeature[] {
|
|
return parsedFeatures.filter(f => f.category === category);
|
|
}
|
|
|
|
export function getFeaturesByPriority(priority: number): ParsedFeature[] {
|
|
return parsedFeatures.filter(f => f.priority === priority);
|
|
}
|
|
|
|
export function getAllCategories(): string[] {
|
|
return [...new Set(parsedFeatures.map(f => f.category))];
|
|
}
|
|
|
|
export function getAllPriorities(): number[] {
|
|
return [...new Set(parsedFeatures.map(f => f.priority))].sort((a, b) => a - b);
|
|
}
|
|
`;
|
|
|
|
await Bun.write('src/shared/data/features.ts', output);
|
|
console.log(`Parsed ${features.length} feature files`);
|
|
return features;
|
|
}
|
|
|
|
function parseGherkinContent(content: string, filePath: string): ParsedFeature | null {
|
|
const lines = content.split('\n');
|
|
|
|
// Extract tags from the beginning
|
|
const tagLines: string[] = [];
|
|
let contentStartIndex = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]?.trim() ?? '';
|
|
if (line.startsWith('#')) {
|
|
contentStartIndex = i + 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith('@')) {
|
|
tagLines.push(line);
|
|
contentStartIndex = i + 1;
|
|
} else if (line !== '') {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const tagMatches = tagLines.join(' ').match(/@[\w-]+/g) || [];
|
|
|
|
// Extract category and priority from tags
|
|
const categoryTag = tagMatches.find(t =>
|
|
['@WORKSHOP', '@EVENT', '@USER', '@MEETING', '@NOTIF'].includes(t)
|
|
);
|
|
const category = categoryTag ? categoryTag.slice(1) : 'UNKNOWN';
|
|
|
|
const priorityTag = tagMatches.find(t => t.startsWith('@priority-'));
|
|
const priority = priorityTag ? parseInt(priorityTag.replace('@priority-', '')) : 3;
|
|
|
|
// Extract feature name
|
|
const featureMatch = content.match(/Fonctionnalité:\s*(.+?)(?:\n|$)/);
|
|
const name = featureMatch?.[1]?.trim() || 'Unknown Feature';
|
|
|
|
// Extract US ID from name
|
|
const idMatch = name.match(/US-(\d+)/i);
|
|
const id = idMatch ? `us-${idMatch[1]}` : filePath.replace(/.*\//, '').replace('.feature', '');
|
|
|
|
// Extract description (lines after Feature until Contexte or Scénario)
|
|
const descLines: string[] = [];
|
|
let inDescription = false;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('Fonctionnalité:')) {
|
|
inDescription = true;
|
|
continue;
|
|
}
|
|
if (inDescription) {
|
|
if (trimmed.startsWith('Contexte:') || trimmed.startsWith('Scénario:') || trimmed.startsWith('Plan du Scénario:')) {
|
|
break;
|
|
}
|
|
if (trimmed && !trimmed.startsWith('@') && !trimmed.startsWith('#')) {
|
|
descLines.push(trimmed);
|
|
}
|
|
}
|
|
}
|
|
const description = descLines.join(' ').trim();
|
|
|
|
// Parse scenarios
|
|
const scenarios: ParsedScenario[] = [];
|
|
let currentScenario: ParsedScenario | null = null;
|
|
let inBackground = false;
|
|
const background: ParsedStep[] = [];
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
|
|
if (trimmed.startsWith('Contexte:')) {
|
|
inBackground = true;
|
|
continue;
|
|
}
|
|
|
|
if (trimmed.startsWith('Scénario:') || trimmed.startsWith('Plan du Scénario:')) {
|
|
inBackground = false;
|
|
if (currentScenario) {
|
|
scenarios.push(currentScenario);
|
|
}
|
|
const scenarioName = trimmed.replace(/^(Scénario:|Plan du Scénario:)\s*/, '').trim();
|
|
currentScenario = {
|
|
name: scenarioName,
|
|
tags: [],
|
|
steps: [],
|
|
};
|
|
continue;
|
|
}
|
|
|
|
// Parse steps
|
|
const stepKeywords = ['Étant donné que ', "Étant donné qu'", 'Étant donné', 'Etant donné que ', "Etant donné qu'", 'Etant donné', 'Quand', 'Lorsque', 'Alors', 'Et', 'Mais'];
|
|
for (const keyword of stepKeywords) {
|
|
if (trimmed.startsWith(keyword)) {
|
|
const step: ParsedStep = {
|
|
keyword,
|
|
text: trimmed.slice(keyword.length).trim(),
|
|
};
|
|
|
|
if (inBackground) {
|
|
background.push(step);
|
|
} else if (currentScenario) {
|
|
currentScenario.steps.push(step);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Don't forget the last scenario
|
|
if (currentScenario) {
|
|
scenarios.push(currentScenario);
|
|
}
|
|
|
|
// Extract screen IDs from all steps (background + all scenarios)
|
|
const allSteps = [...background];
|
|
for (const scenario of scenarios) {
|
|
allSteps.push(...scenario.steps);
|
|
}
|
|
const screenIds = Array.from(extractScreenIdsFromSteps(allSteps)).sort();
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
description,
|
|
tags: tagMatches,
|
|
category,
|
|
priority,
|
|
background: background.length > 0 ? background : undefined,
|
|
scenarios,
|
|
filePath,
|
|
rawContent: content,
|
|
screenIds,
|
|
};
|
|
}
|
|
|
|
// Run parser
|
|
parseFeatures().then(features => {
|
|
console.log('Features by category:');
|
|
const byCategory = features.reduce((acc, f) => {
|
|
acc[f.category] = (acc[f.category] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
console.log(byCategory);
|
|
|
|
console.log('\nFeatures by priority:');
|
|
const byPriority = features.reduce((acc, f) => {
|
|
acc[`P${f.priority}`] = (acc[`P${f.priority}`] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
console.log(byPriority);
|
|
}).catch(console.error);
|