mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 04:27:56 +00:00
Merge pull request #3 from pezkuwichain/claude/claude-md-mi3h6ksbozokaqdw-01J6tpMsypZtDkQr25XiusrK
main
This commit is contained in:
@@ -9,6 +9,7 @@ import WalletScreen from '../screens/WalletScreen';
|
||||
import SwapScreen from '../screens/SwapScreen';
|
||||
import P2PScreen from '../screens/P2PScreen';
|
||||
import EducationScreen from '../screens/EducationScreen';
|
||||
import ForumScreen from '../screens/ForumScreen';
|
||||
import BeCitizenScreen from '../screens/BeCitizenScreen';
|
||||
import ReferralScreen from '../screens/ReferralScreen';
|
||||
import ProfileScreen from '../screens/ProfileScreen';
|
||||
@@ -19,6 +20,7 @@ export type BottomTabParamList = {
|
||||
Swap: undefined;
|
||||
P2P: undefined;
|
||||
Education: undefined;
|
||||
Forum: undefined;
|
||||
BeCitizen: undefined;
|
||||
Referral: undefined;
|
||||
Profile: undefined;
|
||||
@@ -112,6 +114,18 @@ const BottomTabNavigator: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="Forum"
|
||||
component={ForumScreen}
|
||||
options={{
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Text style={[styles.icon, { color }]}>
|
||||
{focused ? '💬' : '📝'}
|
||||
</Text>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tab.Screen
|
||||
name="BeCitizen"
|
||||
component={BeCitizenScreen}
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NavigationProp } from '@react-navigation/native';
|
||||
import type { BottomTabParamList } from '../navigation/BottomTabNavigator';
|
||||
import AppColors, { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface DashboardScreenProps {
|
||||
@@ -22,49 +27,86 @@ const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
onNavigateToSettings,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<BottomTabParamList>>();
|
||||
|
||||
const menuItems = [
|
||||
const showComingSoon = (featureName: string) => {
|
||||
Alert.alert(
|
||||
'Coming Soon',
|
||||
`${featureName} feature is under development and will be available soon!`,
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
key: 'wallet',
|
||||
title: t('dashboard.wallet'),
|
||||
icon: '💼',
|
||||
color: KurdistanColors.kesk,
|
||||
onPress: onNavigateToWallet,
|
||||
key: 'education',
|
||||
title: 'Education',
|
||||
image: require('../../../shared/images/quick-actions/qa_education.png'),
|
||||
available: true,
|
||||
onPress: () => navigation.navigate('Education'),
|
||||
},
|
||||
{
|
||||
key: 'staking',
|
||||
title: t('dashboard.staking'),
|
||||
icon: '🔒',
|
||||
color: KurdistanColors.zer,
|
||||
onPress: () => console.log('Navigate to Staking'),
|
||||
key: 'exchange',
|
||||
title: 'Exchange',
|
||||
image: require('../../../shared/images/quick-actions/qa_exchange.png'),
|
||||
available: true,
|
||||
onPress: () => navigation.navigate('Swap'),
|
||||
},
|
||||
{
|
||||
key: 'forum',
|
||||
title: 'Forum',
|
||||
image: require('../../../shared/images/quick-actions/qa_forum.jpg'),
|
||||
available: true,
|
||||
onPress: () => navigation.navigate('Forum'),
|
||||
},
|
||||
{
|
||||
key: 'governance',
|
||||
title: t('dashboard.governance'),
|
||||
icon: '🗳️',
|
||||
color: KurdistanColors.sor,
|
||||
onPress: () => console.log('Navigate to Governance'),
|
||||
title: 'Governance',
|
||||
image: require('../../../shared/images/quick-actions/qa_governance.jpg'),
|
||||
available: true,
|
||||
onPress: () => navigation.navigate('Home'), // TODO: Navigate to Governance screen
|
||||
},
|
||||
{
|
||||
key: 'dex',
|
||||
title: t('dashboard.dex'),
|
||||
icon: '💱',
|
||||
color: '#2196F3',
|
||||
onPress: () => console.log('Navigate to DEX'),
|
||||
key: 'trading',
|
||||
title: 'Trading',
|
||||
image: require('../../../shared/images/quick-actions/qa_trading.jpg'),
|
||||
available: true,
|
||||
onPress: () => navigation.navigate('P2P'),
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
title: t('dashboard.history'),
|
||||
icon: '📜',
|
||||
color: '#9C27B0',
|
||||
onPress: () => console.log('Navigate to History'),
|
||||
key: 'b2b',
|
||||
title: 'B2B Trading',
|
||||
image: require('../../../shared/images/quick-actions/qa_b2b.png'),
|
||||
available: false,
|
||||
onPress: () => showComingSoon('B2B Trading'),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
title: t('dashboard.settings'),
|
||||
icon: '⚙️',
|
||||
color: '#607D8B',
|
||||
onPress: onNavigateToSettings,
|
||||
key: 'bank',
|
||||
title: 'Banking',
|
||||
image: require('../../../shared/images/quick-actions/qa_bank.png'),
|
||||
available: false,
|
||||
onPress: () => showComingSoon('Banking'),
|
||||
},
|
||||
{
|
||||
key: 'games',
|
||||
title: 'Games',
|
||||
image: require('../../../shared/images/quick-actions/qa_games.png'),
|
||||
available: false,
|
||||
onPress: () => showComingSoon('Games'),
|
||||
},
|
||||
{
|
||||
key: 'kurdmedia',
|
||||
title: 'Kurd Media',
|
||||
image: require('../../../shared/images/quick-actions/qa_kurdmedia.jpg'),
|
||||
available: false,
|
||||
onPress: () => showComingSoon('Kurd Media'),
|
||||
},
|
||||
{
|
||||
key: 'university',
|
||||
title: 'University',
|
||||
image: require('../../../shared/images/quick-actions/qa_university.png'),
|
||||
available: false,
|
||||
onPress: () => showComingSoon('University'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -110,20 +152,32 @@ const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
{/* Quick Actions */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
<View style={styles.menuGrid}>
|
||||
{menuItems.map((item) => (
|
||||
<View style={styles.quickActionsGrid}>
|
||||
{quickActions.map((action) => (
|
||||
<TouchableOpacity
|
||||
key={item.key}
|
||||
style={styles.menuItem}
|
||||
onPress={item.onPress}
|
||||
key={action.key}
|
||||
style={[
|
||||
styles.quickActionItem,
|
||||
!action.available && styles.quickActionDisabled
|
||||
]}
|
||||
onPress={action.onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[styles.menuIconContainer, { backgroundColor: item.color }]}
|
||||
>
|
||||
<Text style={styles.menuIcon}>{item.icon}</Text>
|
||||
<View style={styles.quickActionImageContainer}>
|
||||
<Image
|
||||
source={action.image}
|
||||
style={styles.quickActionImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{!action.available && (
|
||||
<View style={styles.comingSoonBadge}>
|
||||
<Text style={styles.comingSoonText}>Soon</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.menuTitle}>{item.title}</Text>
|
||||
<Text style={styles.quickActionTitle} numberOfLines={2}>
|
||||
{action.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -239,39 +293,56 @@ const styles = StyleSheet.create({
|
||||
color: KurdistanColors.reş,
|
||||
marginBottom: 16,
|
||||
},
|
||||
menuGrid: {
|
||||
quickActionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
menuItem: {
|
||||
width: '47%',
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
quickActionItem: {
|
||||
width: '30%',
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
quickActionDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
quickActionImageContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
position: 'relative',
|
||||
},
|
||||
menuIconContainer: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
quickActionImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
menuIcon: {
|
||||
fontSize: 28,
|
||||
comingSoonBadge: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
backgroundColor: KurdistanColors.zer,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
menuTitle: {
|
||||
fontSize: 14,
|
||||
comingSoonText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.reş,
|
||||
},
|
||||
quickActionTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.reş,
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
},
|
||||
proposalsCard: {
|
||||
backgroundColor: KurdistanColors.spi,
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SafeAreaView,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Badge } from '../components';
|
||||
import { KurdistanColors, AppColors } from '../theme/colors';
|
||||
|
||||
interface ForumThread {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
author: string;
|
||||
category: string;
|
||||
replies_count: number;
|
||||
views_count: number;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
is_pinned: boolean;
|
||||
is_locked: boolean;
|
||||
}
|
||||
|
||||
interface ForumCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
threads_count: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: ForumCategory[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'General Discussion',
|
||||
description: 'General topics about PezkuwiChain',
|
||||
threads_count: 42,
|
||||
icon: '💬',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Governance',
|
||||
description: 'Discuss proposals and governance',
|
||||
threads_count: 28,
|
||||
icon: '🏛️',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Technical',
|
||||
description: 'Development and technical discussions',
|
||||
threads_count: 35,
|
||||
icon: '⚙️',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Trading',
|
||||
description: 'Market discussions and trading',
|
||||
threads_count: 18,
|
||||
icon: '📈',
|
||||
},
|
||||
];
|
||||
|
||||
// Mock data - will be replaced with Supabase
|
||||
const MOCK_THREADS: ForumThread[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Welcome to PezkuwiChain Forum!',
|
||||
content: 'Introduce yourself and join the community...',
|
||||
author: '5GrwV...xQjz',
|
||||
category: 'General Discussion',
|
||||
replies_count: 24,
|
||||
views_count: 156,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
last_activity: '2024-01-20T14:30:00Z',
|
||||
is_pinned: true,
|
||||
is_locked: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'New Governance Proposal: Treasury Allocation',
|
||||
content: 'Discussion about treasury fund allocation...',
|
||||
author: '5HpG8...kLm2',
|
||||
category: 'Governance',
|
||||
replies_count: 45,
|
||||
views_count: 289,
|
||||
created_at: '2024-01-18T09:15:00Z',
|
||||
last_activity: '2024-01-20T16:45:00Z',
|
||||
is_pinned: false,
|
||||
is_locked: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'How to stake PEZ tokens?',
|
||||
content: 'Guide for staking PEZ tokens...',
|
||||
author: '5FHne...pQr8',
|
||||
category: 'General Discussion',
|
||||
replies_count: 12,
|
||||
views_count: 98,
|
||||
created_at: '2024-01-19T11:20:00Z',
|
||||
last_activity: '2024-01-20T13:10:00Z',
|
||||
is_pinned: false,
|
||||
is_locked: false,
|
||||
},
|
||||
];
|
||||
|
||||
type ViewType = 'categories' | 'threads';
|
||||
|
||||
const ForumScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [viewType, setViewType] = useState<ViewType>('categories');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchThreads = async (categoryId?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// TODO: Fetch from Supabase
|
||||
// const { data } = await supabase
|
||||
// .from('forum_threads')
|
||||
// .select('*')
|
||||
// .eq('category_id', categoryId)
|
||||
// .order('is_pinned', { ascending: false })
|
||||
// .order('last_activity', { ascending: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setThreads(MOCK_THREADS);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch threads:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchThreads(selectedCategory || undefined);
|
||||
};
|
||||
|
||||
const handleCategoryPress = (categoryId: string, categoryName: string) => {
|
||||
setSelectedCategory(categoryId);
|
||||
setViewType('threads');
|
||||
fetchThreads(categoryId);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'Just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
};
|
||||
|
||||
const renderCategoryCard = ({ item }: { item: ForumCategory }) => (
|
||||
<TouchableOpacity onPress={() => handleCategoryPress(item.id, item.name)}>
|
||||
<Card style={styles.categoryCard}>
|
||||
<View style={styles.categoryHeader}>
|
||||
<View style={styles.categoryIcon}>
|
||||
<Text style={styles.categoryIconText}>{item.icon}</Text>
|
||||
</View>
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={styles.categoryName}>{item.name}</Text>
|
||||
<Text style={styles.categoryDescription} numberOfLines={2}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.categoryFooter}>
|
||||
<Text style={styles.categoryStats}>
|
||||
{item.threads_count} threads
|
||||
</Text>
|
||||
<Text style={styles.categoryArrow}>→</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderThreadCard = ({ item }: { item: ForumThread }) => (
|
||||
<TouchableOpacity>
|
||||
<Card style={styles.threadCard}>
|
||||
{/* Thread Header */}
|
||||
<View style={styles.threadHeader}>
|
||||
{item.is_pinned && (
|
||||
<View style={styles.pinnedBadge}>
|
||||
<Text style={styles.pinnedIcon}>📌</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.threadTitle} numberOfLines={2}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.is_locked && (
|
||||
<Text style={styles.lockedIcon}>🔒</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Thread Meta */}
|
||||
<View style={styles.threadMeta}>
|
||||
<Text style={styles.threadAuthor}>by {item.author}</Text>
|
||||
<Badge text={item.category} variant="outline" />
|
||||
</View>
|
||||
|
||||
{/* Thread Stats */}
|
||||
<View style={styles.threadStats}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statIcon}>💬</Text>
|
||||
<Text style={styles.statText}>{item.replies_count}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statIcon}>👁️</Text>
|
||||
<Text style={styles.statText}>{item.views_count}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statIcon}>🕐</Text>
|
||||
<Text style={styles.statText}>
|
||||
{formatTimeAgo(item.last_activity)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>💬</Text>
|
||||
<Text style={styles.emptyTitle}>No Threads Yet</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
Be the first to start a discussion in this category
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>
|
||||
{viewType === 'categories' ? 'Forum' : 'Threads'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{viewType === 'categories'
|
||||
? 'Join the community discussion'
|
||||
: selectedCategory || 'All threads'}
|
||||
</Text>
|
||||
</View>
|
||||
{viewType === 'threads' && (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => setViewType('categories')}
|
||||
>
|
||||
<Text style={styles.backButtonText}>← Back</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{loading && !refreshing ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
) : viewType === 'categories' ? (
|
||||
<FlatList
|
||||
data={CATEGORIES}
|
||||
renderItem={renderCategoryCard}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={KurdistanColors.kesk}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FlatList
|
||||
data={threads}
|
||||
renderItem={renderThreadCard}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={KurdistanColors.kesk}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Thread FAB */}
|
||||
{viewType === 'threads' && (
|
||||
<TouchableOpacity style={styles.fab}>
|
||||
<Text style={styles.fabIcon}>✏️</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
backButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
categoryCard: {
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
categoryIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F0F9F4',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
categoryIconText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
categoryInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
categoryName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 4,
|
||||
},
|
||||
categoryDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
categoryFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
},
|
||||
categoryStats: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
categoryArrow: {
|
||||
fontSize: 20,
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
threadCard: {
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
threadHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
pinnedBadge: {
|
||||
marginRight: 8,
|
||||
},
|
||||
pinnedIcon: {
|
||||
fontSize: 16,
|
||||
},
|
||||
threadTitle: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
lineHeight: 22,
|
||||
},
|
||||
lockedIcon: {
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
threadMeta: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
threadAuthor: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
threadStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#000',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
fabIcon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
});
|
||||
|
||||
export default ForumScreen;
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RefreshControl,
|
||||
Alert,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { usePolkadot } from '../contexts/PolkadotContext';
|
||||
import { AppColors, KurdistanColors } from '../theme/colors';
|
||||
@@ -31,6 +33,27 @@ interface Proposal {
|
||||
endBlock: number;
|
||||
}
|
||||
|
||||
interface Election {
|
||||
id: number;
|
||||
type: 'Presidential' | 'Parliamentary' | 'Constitutional Court';
|
||||
status: 'Registration' | 'Campaign' | 'Voting' | 'Completed';
|
||||
candidates: Candidate[];
|
||||
totalVotes: number;
|
||||
endBlock: number;
|
||||
currentBlock: number;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
id: string;
|
||||
name: string;
|
||||
votes: number;
|
||||
percentage: number;
|
||||
party?: string;
|
||||
trustScore: number;
|
||||
}
|
||||
|
||||
type TabType = 'proposals' | 'elections' | 'parliament';
|
||||
|
||||
/**
|
||||
* Governance Screen
|
||||
* View proposals, vote, participate in governance
|
||||
@@ -38,16 +61,22 @@ interface Proposal {
|
||||
*/
|
||||
export default function GovernanceScreen() {
|
||||
const { api, selectedAccount, isApiReady } = usePolkadot();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('proposals');
|
||||
const [proposals, setProposals] = useState<Proposal[]>([]);
|
||||
const [elections, setElections] = useState<Election[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedProposal, setSelectedProposal] = useState<Proposal | null>(null);
|
||||
const [selectedElection, setSelectedElection] = useState<Election | null>(null);
|
||||
const [voteSheetVisible, setVoteSheetVisible] = useState(false);
|
||||
const [electionSheetVisible, setElectionSheetVisible] = useState(false);
|
||||
const [voting, setVoting] = useState(false);
|
||||
const [votedCandidates, setVotedCandidates] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isApiReady && selectedAccount) {
|
||||
fetchProposals();
|
||||
fetchElections();
|
||||
}
|
||||
}, [isApiReady, selectedAccount]);
|
||||
|
||||
@@ -100,6 +129,43 @@ export default function GovernanceScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchElections = async () => {
|
||||
try {
|
||||
// Mock elections data
|
||||
// In production, this would fetch from pallet-tiki or election pallet
|
||||
const mockElections: Election[] = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'Presidential',
|
||||
status: 'Voting',
|
||||
totalVotes: 45678,
|
||||
endBlock: 1000000,
|
||||
currentBlock: 995000,
|
||||
candidates: [
|
||||
{ id: '1', name: 'Candidate A', votes: 23456, percentage: 51.3, trustScore: 850 },
|
||||
{ id: '2', name: 'Candidate B', votes: 22222, percentage: 48.7, trustScore: 780 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'Parliamentary',
|
||||
status: 'Campaign',
|
||||
totalVotes: 12340,
|
||||
endBlock: 1200000,
|
||||
currentBlock: 995000,
|
||||
candidates: [
|
||||
{ id: '3', name: 'Candidate C', votes: 5678, percentage: 46.0, party: 'Green Party', trustScore: 720 },
|
||||
{ id: '4', name: 'Candidate D', votes: 4567, percentage: 37.0, party: 'Democratic Alliance', trustScore: 690 },
|
||||
{ id: '5', name: 'Candidate E', votes: 2095, percentage: 17.0, party: 'Independent', trustScore: 650 }
|
||||
]
|
||||
}
|
||||
];
|
||||
setElections(mockElections);
|
||||
} catch (error) {
|
||||
console.error('Error fetching elections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (approve: boolean) => {
|
||||
if (!selectedProposal) return;
|
||||
|
||||
@@ -132,6 +198,46 @@ export default function GovernanceScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleElectionVote = (candidateId: string) => {
|
||||
if (!selectedElection) return;
|
||||
|
||||
if (selectedElection.type === 'Parliamentary') {
|
||||
// Multiple selection for Parliamentary
|
||||
setVotedCandidates(prev =>
|
||||
prev.includes(candidateId)
|
||||
? prev.filter(id => id !== candidateId)
|
||||
: [...prev, candidateId]
|
||||
);
|
||||
} else {
|
||||
// Single selection for Presidential
|
||||
setVotedCandidates([candidateId]);
|
||||
}
|
||||
};
|
||||
|
||||
const submitElectionVote = async () => {
|
||||
if (votedCandidates.length === 0) {
|
||||
Alert.alert('Error', 'Please select at least one candidate');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setVoting(true);
|
||||
// TODO: Submit votes to blockchain via pallet-tiki
|
||||
// await api.tx.tiki.voteInElection(electionId, candidateIds).signAndSend(...)
|
||||
|
||||
Alert.alert('Success', 'Your vote has been recorded!');
|
||||
setElectionSheetVisible(false);
|
||||
setSelectedElection(null);
|
||||
setVotedCandidates([]);
|
||||
fetchElections();
|
||||
} catch (error: any) {
|
||||
console.error('Election voting error:', error);
|
||||
Alert.alert('Error', error.message || 'Failed to submit vote');
|
||||
} finally {
|
||||
setVoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && proposals.length === 0) {
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
@@ -144,6 +250,44 @@ export default function GovernanceScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Governance</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Participate in digital democracy
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={styles.tabs}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'proposals' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('proposals')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'proposals' && styles.activeTabText]}>
|
||||
Proposals ({proposals.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'elections' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('elections')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'elections' && styles.activeTabText]}>
|
||||
Elections ({elections.length})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'parliament' && styles.activeTab]}
|
||||
onPress={() => setActiveTab('parliament')}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'parliament' && styles.activeTabText]}>
|
||||
Parliament
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
@@ -152,18 +296,11 @@ export default function GovernanceScreen() {
|
||||
onRefresh={() => {
|
||||
setRefreshing(true);
|
||||
fetchProposals();
|
||||
fetchElections();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Governance</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Participate in digital democracy
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats */}
|
||||
<View style={styles.statsRow}>
|
||||
<Card style={styles.statCard}>
|
||||
@@ -171,31 +308,97 @@ export default function GovernanceScreen() {
|
||||
<Text style={styles.statLabel}>Active Proposals</Text>
|
||||
</Card>
|
||||
<Card style={styles.statCard}>
|
||||
<Text style={styles.statValue}>1,234</Text>
|
||||
<Text style={styles.statLabel}>Total Voters</Text>
|
||||
<Text style={styles.statValue}>{elections.length}</Text>
|
||||
<Text style={styles.statLabel}>Active Elections</Text>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
{/* Proposals List */}
|
||||
<Text style={styles.sectionTitle}>Active Proposals</Text>
|
||||
{proposals.length === 0 ? (
|
||||
<Card style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>No active proposals</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Check back later for new governance proposals
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
key={proposal.index}
|
||||
proposal={proposal}
|
||||
onPress={() => {
|
||||
setSelectedProposal(proposal);
|
||||
setVoteSheetVisible(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'proposals' && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Active Proposals</Text>
|
||||
{proposals.length === 0 ? (
|
||||
<Card style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>No active proposals</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Check back later for new governance proposals
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
key={proposal.index}
|
||||
proposal={proposal}
|
||||
onPress={() => {
|
||||
setSelectedProposal(proposal);
|
||||
setVoteSheetVisible(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'elections' && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Active Elections</Text>
|
||||
{elections.length === 0 ? (
|
||||
<Card style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>No active elections</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Check back later for upcoming elections
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
elections.map((election) => (
|
||||
<ElectionCard
|
||||
key={election.id}
|
||||
election={election}
|
||||
onPress={() => {
|
||||
setSelectedElection(election);
|
||||
setVotedCandidates([]);
|
||||
setElectionSheetVisible(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'parliament' && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Parliament Status</Text>
|
||||
<Card style={styles.parliamentCard}>
|
||||
<View style={styles.parliamentRow}>
|
||||
<Text style={styles.parliamentLabel}>Active Members</Text>
|
||||
<Text style={styles.parliamentValue}>0 / 27</Text>
|
||||
</View>
|
||||
<View style={styles.parliamentRow}>
|
||||
<Text style={styles.parliamentLabel}>Current Session</Text>
|
||||
<Badge label="In Session" variant="success" size="small" />
|
||||
</View>
|
||||
<View style={styles.parliamentRow}>
|
||||
<Text style={styles.parliamentLabel}>Pending Votes</Text>
|
||||
<Text style={styles.parliamentValue}>5</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Text style={styles.sectionTitle}>Dîwan (Constitutional Court)</Text>
|
||||
<Card style={styles.parliamentCard}>
|
||||
<View style={styles.parliamentRow}>
|
||||
<Text style={styles.parliamentLabel}>Active Judges</Text>
|
||||
<Text style={styles.parliamentValue}>0 / 9</Text>
|
||||
</View>
|
||||
<View style={styles.parliamentRow}>
|
||||
<Text style={styles.parliamentLabel}>Pending Reviews</Text>
|
||||
<Text style={styles.parliamentValue}>3</Text>
|
||||
</View>
|
||||
<View style={styles.parliamentRow}>
|
||||
<Text style={styles.parliamentLabel}>Recent Decisions</Text>
|
||||
<Text style={styles.parliamentValue}>12</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
@@ -265,10 +468,148 @@ export default function GovernanceScreen() {
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
|
||||
{/* Election Vote Bottom Sheet */}
|
||||
<BottomSheet
|
||||
visible={electionSheetVisible}
|
||||
onClose={() => setElectionSheetVisible(false)}
|
||||
title={selectedElection ? `${selectedElection.type} Election` : 'Election'}
|
||||
height={600}
|
||||
>
|
||||
{selectedElection && (
|
||||
<View>
|
||||
<View style={styles.electionHeader}>
|
||||
<Badge
|
||||
label={selectedElection.status}
|
||||
variant={selectedElection.status === 'Voting' ? 'info' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
<Text style={styles.electionVotes}>
|
||||
{selectedElection.totalVotes.toLocaleString()} votes cast
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.electionInstruction}>
|
||||
{selectedElection.type === 'Parliamentary'
|
||||
? 'You can select multiple candidates'
|
||||
: 'Select one candidate'}
|
||||
</Text>
|
||||
|
||||
{/* Candidates List */}
|
||||
<ScrollView style={styles.candidatesList}>
|
||||
{selectedElection.candidates.map((candidate) => (
|
||||
<TouchableOpacity
|
||||
key={candidate.id}
|
||||
style={[
|
||||
styles.candidateCard,
|
||||
votedCandidates.includes(candidate.id) && styles.candidateCardSelected
|
||||
]}
|
||||
onPress={() => handleElectionVote(candidate.id)}
|
||||
>
|
||||
<View style={styles.candidateHeader}>
|
||||
<View>
|
||||
<Text style={styles.candidateName}>{candidate.name}</Text>
|
||||
{candidate.party && (
|
||||
<Text style={styles.candidateParty}>{candidate.party}</Text>
|
||||
)}
|
||||
<Text style={styles.candidateTrust}>
|
||||
Trust Score: {candidate.trustScore}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.candidateStats}>
|
||||
<Text style={styles.candidatePercentage}>
|
||||
{candidate.percentage.toFixed(1)}%
|
||||
</Text>
|
||||
<Text style={styles.candidateVotes}>
|
||||
{candidate.votes.toLocaleString()} votes
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${candidate.percentage}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{votedCandidates.includes(candidate.id) && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text style={styles.selectedText}>✓ Selected</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Submit Vote Button */}
|
||||
<Button
|
||||
title={`Submit ${votedCandidates.length > 0 ? `(${votedCandidates.length})` : ''} Vote`}
|
||||
onPress={submitElectionVote}
|
||||
loading={voting}
|
||||
disabled={voting || votedCandidates.length === 0}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const ElectionCard: React.FC<{
|
||||
election: Election;
|
||||
onPress: () => void;
|
||||
}> = ({ election, onPress }) => {
|
||||
const blocksLeft = election.endBlock - election.currentBlock;
|
||||
|
||||
return (
|
||||
<Card onPress={onPress} style={styles.electionCard}>
|
||||
<View style={styles.electionCardHeader}>
|
||||
<View>
|
||||
<Text style={styles.electionType}>{election.type}</Text>
|
||||
<Text style={styles.electionStatus}>{election.status}</Text>
|
||||
</View>
|
||||
<Badge
|
||||
label={election.status}
|
||||
variant={election.status === 'Voting' ? 'info' : 'warning'}
|
||||
size="small"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.electionStats}>
|
||||
<View style={styles.electionStat}>
|
||||
<Text style={styles.electionStatLabel}>Candidates</Text>
|
||||
<Text style={styles.electionStatValue}>{election.candidates.length}</Text>
|
||||
</View>
|
||||
<View style={styles.electionStat}>
|
||||
<Text style={styles.electionStatLabel}>Total Votes</Text>
|
||||
<Text style={styles.electionStatValue}>
|
||||
{election.totalVotes.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.electionStat}>
|
||||
<Text style={styles.electionStatLabel}>Blocks Left</Text>
|
||||
<Text style={styles.electionStatValue}>
|
||||
{blocksLeft.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{election.candidates.length > 0 && (
|
||||
<View style={styles.leadingCandidate}>
|
||||
<Text style={styles.leadingLabel}>Leading:</Text>
|
||||
<Text style={styles.leadingName}>
|
||||
{election.candidates[0].name} ({election.candidates[0].percentage.toFixed(1)}%)
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ProposalCard: React.FC<{
|
||||
proposal: Proposal;
|
||||
onPress: () => void;
|
||||
@@ -487,4 +828,181 @@ const styles = StyleSheet.create({
|
||||
voteButtons: {
|
||||
gap: 12,
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomColor: KurdistanColors.kesk,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
activeTabText: {
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
electionCard: {
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
},
|
||||
electionCardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
},
|
||||
electionType: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: AppColors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
electionStatus: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
electionStats: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
electionStat: {
|
||||
flex: 1,
|
||||
},
|
||||
electionStatLabel: {
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
electionStatValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
},
|
||||
leadingCandidate: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: AppColors.border,
|
||||
},
|
||||
leadingLabel: {
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
marginRight: 8,
|
||||
},
|
||||
leadingName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
electionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
electionVotes: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
electionInstruction: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
candidatesList: {
|
||||
maxHeight: 350,
|
||||
},
|
||||
candidateCard: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.border,
|
||||
marginBottom: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
candidateCardSelected: {
|
||||
borderColor: KurdistanColors.kesk,
|
||||
backgroundColor: '#F0F9F4',
|
||||
},
|
||||
candidateHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
candidateName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: AppColors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
candidateParty: {
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
candidateTrust: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
},
|
||||
candidateStats: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
candidatePercentage: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: KurdistanColors.kesk,
|
||||
marginBottom: 2,
|
||||
},
|
||||
candidateVotes: {
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
selectedBadge: {
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
borderRadius: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
parliamentCard: {
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
},
|
||||
parliamentRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
parliamentLabel: {
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
parliamentValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: AppColors.text,
|
||||
},
|
||||
});
|
||||
|
||||
+693
@@ -0,0 +1,693 @@
|
||||
# PezkuwiChain Frontend Test Suite
|
||||
|
||||
> **Comprehensive Testing Framework** based on blockchain pallet test scenarios
|
||||
> **437 test functions** extracted from 12 pallets
|
||||
> **71 success scenarios** + **58 failure scenarios** + **33 user flows**
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Test Coverage](#test-coverage)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Test Structure](#test-structure)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Test Scenarios](#test-scenarios)
|
||||
- [Writing New Tests](#writing-new-tests)
|
||||
- [CI/CD Integration](#cicd-integration)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
This test suite provides comprehensive coverage for both **Web** and **Mobile** frontends, directly mapped to blockchain pallet functionality. Every test scenario is based on actual Rust tests from `scripts/tests/*.rs`.
|
||||
|
||||
### Test Types
|
||||
|
||||
| Type | Framework | Target | Coverage |
|
||||
|------|-----------|--------|----------|
|
||||
| **Unit Tests** | Jest + RTL | Components | ~70% |
|
||||
| **Integration Tests** | Jest | State + API | ~60% |
|
||||
| **E2E Tests (Web)** | Cypress | Full Flows | ~40% |
|
||||
| **E2E Tests (Mobile)** | Detox | Full Flows | ~40% |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Test Coverage
|
||||
|
||||
### By Pallet
|
||||
|
||||
| Pallet | Test Functions | Frontend Components | Test Files |
|
||||
|--------|---------------|---------------------|------------|
|
||||
| Identity-KYC | 39 | KYCApplication, CitizenStatus | 3 files |
|
||||
| Perwerde (Education) | 30 | CourseList, Enrollment | 3 files |
|
||||
| PEZ Rewards | 44 | EpochDashboard, ClaimRewards | 2 files |
|
||||
| PEZ Treasury | 58 | TreasuryDashboard, MonthlyRelease | 2 files |
|
||||
| Presale | 24 | PresaleWidget | 1 file |
|
||||
| Referral | 17 | ReferralDashboard, InviteUser | 2 files |
|
||||
| Staking Score | 23 | StakingScoreWidget | 1 file |
|
||||
| Tiki (Roles) | 66 | RoleBadges, GovernanceRoles | 3 files |
|
||||
| Token Wrapper | 18 | TokenWrapper | 1 file |
|
||||
| Trust Score | 26 | TrustScoreWidget | 1 file |
|
||||
| Validator Pool | 27 | ValidatorPool, Performance | 2 files |
|
||||
| Welati (Governance) | 65 | ElectionWidget, ProposalList | 4 files |
|
||||
|
||||
### By Feature
|
||||
|
||||
| Feature | Web Tests | Mobile Tests | Total |
|
||||
|---------|-----------|--------------|-------|
|
||||
| Citizenship & KYC | 12 unit + 1 E2E | 8 unit + 1 E2E | 22 |
|
||||
| Education Platform | 10 unit + 1 E2E | 12 unit + 1 E2E | 24 |
|
||||
| Governance & Elections | 15 unit + 2 E2E | 10 unit + 1 E2E | 28 |
|
||||
| P2P Trading | 8 unit + 1 E2E | 6 unit + 1 E2E | 16 |
|
||||
| Rewards & Treasury | 12 unit | 0 | 12 |
|
||||
| **TOTAL** | **57 unit + 5 E2E** | **36 unit + 4 E2E** | **102** |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Web dependencies
|
||||
cd web
|
||||
npm install
|
||||
|
||||
# Install Cypress (E2E)
|
||||
npm install --save-dev cypress @cypress/react
|
||||
|
||||
# Mobile dependencies
|
||||
cd mobile
|
||||
npm install
|
||||
|
||||
# Install Detox (E2E)
|
||||
npm install --save-dev detox detox-cli
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
# Web tests
|
||||
./tests/run-web-tests.sh
|
||||
|
||||
# Mobile tests
|
||||
./tests/run-mobile-tests.sh
|
||||
```
|
||||
|
||||
### Run Specific Test Suite
|
||||
|
||||
```bash
|
||||
# Web: Citizenship tests only
|
||||
./tests/run-web-tests.sh suite citizenship
|
||||
|
||||
# Mobile: Education tests only
|
||||
./tests/run-mobile-tests.sh suite education
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup/ # Test configuration
|
||||
│ ├── web-test-setup.ts # Jest setup for web
|
||||
│ └── mobile-test-setup.ts # Jest setup for mobile
|
||||
│
|
||||
├── utils/ # Shared utilities
|
||||
│ ├── mockDataGenerators.ts # Mock data based on pallet tests
|
||||
│ ├── testHelpers.ts # Common test helpers
|
||||
│ └── blockchainHelpers.ts # Blockchain mock utilities
|
||||
│
|
||||
├── web/
|
||||
│ ├── unit/ # Component unit tests
|
||||
│ │ ├── citizenship/ # KYC, Identity tests
|
||||
│ │ ├── education/ # Perwerde tests
|
||||
│ │ ├── governance/ # Elections, Proposals
|
||||
│ │ ├── p2p/ # P2P trading tests
|
||||
│ │ ├── rewards/ # Epoch rewards tests
|
||||
│ │ ├── treasury/ # Treasury tests
|
||||
│ │ ├── referral/ # Referral system tests
|
||||
│ │ ├── staking/ # Staking score tests
|
||||
│ │ ├── validator/ # Validator pool tests
|
||||
│ │ └── wallet/ # Token wrapper tests
|
||||
│ │
|
||||
│ └── e2e/cypress/ # E2E tests
|
||||
│ ├── citizenship-kyc.cy.ts
|
||||
│ ├── education-flow.cy.ts
|
||||
│ ├── governance-voting.cy.ts
|
||||
│ ├── p2p-trading.cy.ts
|
||||
│ └── rewards-claiming.cy.ts
|
||||
│
|
||||
└── mobile/
|
||||
├── unit/ # Component unit tests
|
||||
│ ├── citizenship/
|
||||
│ ├── education/
|
||||
│ ├── governance/
|
||||
│ ├── p2p/
|
||||
│ └── rewards/
|
||||
│
|
||||
└── e2e/detox/ # E2E tests
|
||||
├── education-flow.e2e.ts
|
||||
├── governance-flow.e2e.ts
|
||||
├── p2p-trading.e2e.ts
|
||||
└── wallet-flow.e2e.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Running Tests
|
||||
|
||||
### Web Tests
|
||||
|
||||
#### Unit Tests Only
|
||||
```bash
|
||||
./tests/run-web-tests.sh unit
|
||||
```
|
||||
|
||||
#### E2E Tests Only
|
||||
```bash
|
||||
./tests/run-web-tests.sh e2e
|
||||
```
|
||||
|
||||
#### Watch Mode (for development)
|
||||
```bash
|
||||
./tests/run-web-tests.sh watch
|
||||
```
|
||||
|
||||
#### Coverage Report
|
||||
```bash
|
||||
./tests/run-web-tests.sh coverage
|
||||
# Opens: web/coverage/index.html
|
||||
```
|
||||
|
||||
#### Specific Feature Suite
|
||||
```bash
|
||||
./tests/run-web-tests.sh suite citizenship
|
||||
./tests/run-web-tests.sh suite education
|
||||
./tests/run-web-tests.sh suite governance
|
||||
./tests/run-web-tests.sh suite p2p
|
||||
./tests/run-web-tests.sh suite rewards
|
||||
./tests/run-web-tests.sh suite treasury
|
||||
```
|
||||
|
||||
### Mobile Tests
|
||||
|
||||
#### Unit Tests Only
|
||||
```bash
|
||||
./tests/run-mobile-tests.sh unit
|
||||
```
|
||||
|
||||
#### E2E Tests (iOS)
|
||||
```bash
|
||||
./tests/run-mobile-tests.sh e2e ios
|
||||
```
|
||||
|
||||
#### E2E Tests (Android)
|
||||
```bash
|
||||
./tests/run-mobile-tests.sh e2e android
|
||||
```
|
||||
|
||||
#### Watch Mode
|
||||
```bash
|
||||
./tests/run-mobile-tests.sh watch
|
||||
```
|
||||
|
||||
#### Coverage Report
|
||||
```bash
|
||||
./tests/run-mobile-tests.sh coverage
|
||||
# Opens: mobile/coverage/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Test Scenarios
|
||||
|
||||
### 1. Citizenship & KYC
|
||||
|
||||
**Pallet:** `tests-identity-kyc.rs` (39 tests)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Set identity with name and email
|
||||
- ✅ Apply for KYC with IPFS CIDs and deposit
|
||||
- ✅ Admin approves KYC, deposit refunded
|
||||
- ✅ Self-confirm citizenship (Welati NFT holders)
|
||||
- ✅ Renounce citizenship and reapply
|
||||
|
||||
**Failure Scenarios:**
|
||||
- ❌ Apply for KYC without setting identity first
|
||||
- ❌ Apply for KYC when already pending
|
||||
- ❌ Insufficient balance for deposit
|
||||
- ❌ Name exceeds 50 characters
|
||||
- ❌ Invalid IPFS CID format
|
||||
|
||||
**User Flows:**
|
||||
1. **Full KYC Flow:** Set Identity → Apply for KYC → Admin Approval → Citizen NFT
|
||||
2. **Self-Confirmation:** Set Identity → Apply → Self-Confirm → Citizen NFT
|
||||
3. **Rejection:** Apply → Admin Rejects → Reapply
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/citizenship/KYCApplication.test.tsx`
|
||||
- `tests/web/e2e/cypress/citizenship-kyc.cy.ts`
|
||||
- `tests/mobile/unit/citizenship/KYCForm.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 2. Education Platform (Perwerde)
|
||||
|
||||
**Pallet:** `tests-perwerde.rs` (30 tests)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Admin creates course
|
||||
- ✅ Student enrolls in active course
|
||||
- ✅ Student completes course with points
|
||||
- ✅ Multiple students enroll in same course
|
||||
- ✅ Archive course
|
||||
|
||||
**Failure Scenarios:**
|
||||
- ❌ Non-admin tries to create course
|
||||
- ❌ Enroll in archived course
|
||||
- ❌ Enroll when already enrolled
|
||||
- ❌ Complete course without enrollment
|
||||
- ❌ Exceed 100 course enrollment limit
|
||||
|
||||
**User Flows:**
|
||||
1. **Student Learning:** Browse Courses → Enroll → Study → Complete → Earn Certificate
|
||||
2. **Admin Management:** Create Course → Monitor Enrollments → Archive
|
||||
3. **Multi-Course:** Enroll in multiple courses up to limit
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/education/CourseList.test.tsx`
|
||||
- `tests/mobile/unit/education/CourseList.test.tsx`
|
||||
- `tests/mobile/e2e/detox/education-flow.e2e.ts`
|
||||
|
||||
---
|
||||
|
||||
### 3. Governance & Elections (Welati)
|
||||
|
||||
**Pallet:** `tests-welati.rs` (65 tests)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Initiate election (Presidential, Parliamentary, Constitutional Court)
|
||||
- ✅ Register as candidate with endorsements
|
||||
- ✅ Cast vote during voting period
|
||||
- ✅ Finalize election and determine winners
|
||||
- ✅ Submit and vote on proposals
|
||||
|
||||
**Failure Scenarios:**
|
||||
- ❌ Register without required endorsements
|
||||
- ❌ Register after candidacy deadline
|
||||
- ❌ Vote twice in same election
|
||||
- ❌ Vote outside voting period
|
||||
- ❌ Turnout below required threshold
|
||||
|
||||
**Election Requirements:**
|
||||
- **Presidential:** 600 trust score, 100 endorsements, 50% turnout
|
||||
- **Parliamentary:** 300 trust score, 50 endorsements, 40% turnout
|
||||
- **Constitutional Court:** 750 trust score, 50 endorsements, 30% turnout
|
||||
|
||||
**User Flows:**
|
||||
1. **Voting:** Browse Elections → Select Candidate(s) → Cast Vote → View Results
|
||||
2. **Candidate Registration:** Meet Requirements → Collect Endorsements → Register → Campaign
|
||||
3. **Proposal:** Submit Proposal → Parliament Votes → Execute if Passed
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/governance/ElectionWidget.test.tsx`
|
||||
- `tests/web/e2e/cypress/governance-voting.cy.ts`
|
||||
- `tests/mobile/unit/governance/ElectionList.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 4. P2P Fiat Trading
|
||||
|
||||
**Pallet:** `tests-welati.rs` (P2P section)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Create buy/sell offer
|
||||
- ✅ Accept offer and initiate trade
|
||||
- ✅ Release escrow on completion
|
||||
- ✅ Dispute resolution
|
||||
- ✅ Reputation tracking
|
||||
|
||||
**Failure Scenarios:**
|
||||
- ❌ Create offer with insufficient balance
|
||||
- ❌ Accept own offer
|
||||
- ❌ Release escrow without trade completion
|
||||
- ❌ Invalid payment proof
|
||||
|
||||
**User Flows:**
|
||||
1. **Seller Flow:** Create Sell Offer → Buyer Accepts → Receive Fiat → Release Crypto
|
||||
2. **Buyer Flow:** Browse Offers → Accept Offer → Send Fiat → Receive Crypto
|
||||
3. **Dispute:** Trade Stalled → Open Dispute → Mediator Resolves
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/p2p/OfferList.test.tsx`
|
||||
- `tests/web/e2e/cypress/p2p-trading.cy.ts`
|
||||
- `tests/mobile/unit/p2p/P2PScreen.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 5. Rewards & Treasury
|
||||
|
||||
**Pallets:** `tests-pez-rewards.rs` (44 tests), `tests-pez-treasury.rs` (58 tests)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Record trust score for epoch
|
||||
- ✅ Claim epoch rewards
|
||||
- ✅ Parliamentary NFT holders receive 10%
|
||||
- ✅ Monthly treasury release (75% incentive, 25% government)
|
||||
- ✅ Halving every 48 months
|
||||
|
||||
**Failure Scenarios:**
|
||||
- ❌ Claim reward when already claimed
|
||||
- ❌ Claim without participating in epoch
|
||||
- ❌ Claim after claim period ends
|
||||
- ❌ Release funds before month ends
|
||||
|
||||
**User Flows:**
|
||||
1. **Epoch Participation:** Record Trust Score → Wait for End → Claim Reward
|
||||
2. **Treasury Release:** Monthly Trigger → Incentive/Gov Pots Funded
|
||||
3. **Halving Event:** 48 Months → Amount Halved → New Period Begins
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/rewards/EpochDashboard.test.tsx`
|
||||
- `tests/web/unit/treasury/TreasuryDashboard.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 6. Referral System
|
||||
|
||||
**Pallet:** `tests-referral.rs` (17 tests)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Initiate referral invitation
|
||||
- ✅ Confirm referral on KYC approval
|
||||
- ✅ Referral score calculation with tiers
|
||||
|
||||
**Scoring Tiers:**
|
||||
- 0-10 referrals: score = count × 10
|
||||
- 11-50 referrals: score = 100 + (count - 10) × 5
|
||||
- 51-100 referrals: score = 300 + (count - 50) × 4
|
||||
- 100+ referrals: score = 500 (capped)
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/referral/ReferralDashboard.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 7. Staking Score
|
||||
|
||||
**Pallet:** `tests-staking-score.rs` (23 tests)
|
||||
|
||||
**Base Score Tiers:**
|
||||
- 0-99 HEZ: 0 points
|
||||
- 100-249 HEZ: 20 points
|
||||
- 250-749 HEZ: 30 points
|
||||
- 750+ HEZ: 40 points
|
||||
|
||||
**Duration Multipliers:**
|
||||
- 0 months: 1.0x
|
||||
- 1 month: 1.2x
|
||||
- 3 months: 1.4x
|
||||
- 6 months: 1.7x
|
||||
- 12+ months: 2.0x
|
||||
- **Max final score: 100 (capped)**
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/staking/StakingScoreWidget.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 8. Tiki (Governance Roles)
|
||||
|
||||
**Pallet:** `tests-tiki.rs` (66 tests)
|
||||
|
||||
**Role Types:**
|
||||
- **Automatic:** Welati (10 pts)
|
||||
- **Elected:** Parlementer (100), Serok (200), SerokiMeclise (150)
|
||||
- **Appointed:** Wezir (100), Dadger (150), Dozger (120)
|
||||
- **Earned:** Axa (250), Mamoste (70), Rewsenbîr (80)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Mint Citizen NFT, auto-grant Welati
|
||||
- ✅ Grant appointed/elected/earned roles
|
||||
- ✅ Revoke roles (except Welati)
|
||||
- ✅ Unique role enforcement (Serok, SerokiMeclise, Xezinedar)
|
||||
- ✅ Tiki score calculation
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/tiki/RoleBadges.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 9. Token Wrapper
|
||||
|
||||
**Pallet:** `tests-token-wrapper.rs` (18 tests)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Wrap HEZ → wHEZ (1:1)
|
||||
- ✅ Unwrap wHEZ → HEZ (1:1)
|
||||
- ✅ Multiple wrap/unwrap operations
|
||||
- ✅ 1:1 backing maintained
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/wallet/TokenWrapper.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 10. Trust Score
|
||||
|
||||
**Pallet:** `tests-trust.rs` (26 tests)
|
||||
|
||||
**Formula:**
|
||||
```typescript
|
||||
weighted_sum = (staking × 100) + (referral × 300) + (perwerde × 300) + (tiki × 300)
|
||||
trust_score = staking × weighted_sum / 1000
|
||||
```
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/profile/TrustScoreWidget.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 11. Validator Pool
|
||||
|
||||
**Pallet:** `tests-validator-pool.rs` (27 tests)
|
||||
|
||||
**Categories:**
|
||||
- **Stake Validators:** Trust score 800+
|
||||
- **Parliamentary Validators:** Tiki score required
|
||||
- **Merit Validators:** Tiki + community support
|
||||
|
||||
**Performance Metrics:**
|
||||
- Blocks produced/missed
|
||||
- Reputation score: (blocks_produced × 100) / (blocks_produced + blocks_missed)
|
||||
- Era points earned
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/validator/ValidatorPool.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 12. Presale
|
||||
|
||||
**Pallet:** `tests-presale.rs` (24 tests)
|
||||
|
||||
**Conversion:**
|
||||
- 100 wUSDT (6 decimals) = 10,000 PEZ (12 decimals)
|
||||
|
||||
**Success Scenarios:**
|
||||
- ✅ Start presale with duration
|
||||
- ✅ Contribute wUSDT, receive PEZ
|
||||
- ✅ Multiple contributions accumulate
|
||||
- ✅ Finalize and distribute
|
||||
|
||||
**Test Files:**
|
||||
- `tests/web/unit/presale/PresaleWidget.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
## ✍️ Writing New Tests
|
||||
|
||||
### 1. Component Unit Test Template
|
||||
|
||||
```typescript
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { generateMockCourse } from '../../../utils/mockDataGenerators';
|
||||
import { buildPolkadotContextState } from '../../../utils/testHelpers';
|
||||
|
||||
describe('YourComponent', () => {
|
||||
let mockApi: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApi = buildPolkadotContextState();
|
||||
});
|
||||
|
||||
test('should render correctly', () => {
|
||||
const mockData = generateMockCourse();
|
||||
// Your test logic
|
||||
});
|
||||
|
||||
test('should handle user interaction', async () => {
|
||||
// Your test logic
|
||||
});
|
||||
|
||||
test('should handle error state', () => {
|
||||
// Your test logic
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. E2E Test Template (Cypress)
|
||||
|
||||
```typescript
|
||||
describe('Feature Flow (E2E)', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/feature');
|
||||
});
|
||||
|
||||
it('should complete full flow', () => {
|
||||
// Step 1: Setup
|
||||
cy.get('[data-testid="input"]').type('value');
|
||||
|
||||
// Step 2: Action
|
||||
cy.get('[data-testid="submit-btn"]').click();
|
||||
|
||||
// Step 3: Verify
|
||||
cy.contains('Success').should('be.visible');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Mock Data Generator
|
||||
|
||||
```typescript
|
||||
export const generateMockYourData = () => ({
|
||||
id: Math.floor(Math.random() * 1000),
|
||||
field1: 'value1',
|
||||
field2: Math.random() * 100,
|
||||
// ... match blockchain pallet storage
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 CI/CD Integration
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
```yaml
|
||||
name: Frontend Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
web-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- run: cd web && npm ci
|
||||
- run: ./tests/run-web-tests.sh unit
|
||||
- run: ./tests/run-web-tests.sh e2e
|
||||
|
||||
mobile-tests:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- run: cd mobile && npm ci
|
||||
- run: ./tests/run-mobile-tests.sh unit
|
||||
- run: ./tests/run-mobile-tests.sh e2e ios
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Jest: Module not found
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd web && npm install
|
||||
# or
|
||||
cd mobile && npm install
|
||||
```
|
||||
|
||||
#### Cypress: Cannot find browser
|
||||
```bash
|
||||
# Install Cypress binaries
|
||||
npx cypress install
|
||||
```
|
||||
|
||||
#### Detox: iOS simulator not found
|
||||
```bash
|
||||
# List available simulators
|
||||
xcrun simctl list devices
|
||||
|
||||
# Boot simulator
|
||||
open -a Simulator
|
||||
```
|
||||
|
||||
#### Mock data not matching blockchain
|
||||
```bash
|
||||
# Re-analyze pallet tests
|
||||
cd scripts/tests
|
||||
cargo test -p pallet-identity-kyc -- --nocapture
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
```bash
|
||||
# Web tests with verbose output
|
||||
./tests/run-web-tests.sh unit | tee test-output.log
|
||||
|
||||
# Mobile tests with debug
|
||||
DEBUG=* ./tests/run-mobile-tests.sh unit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
- **Blockchain Pallet Tests:** `scripts/tests/*.rs`
|
||||
- **Mock Data Generators:** `tests/utils/mockDataGenerators.ts`
|
||||
- **Test Helpers:** `tests/utils/testHelpers.ts`
|
||||
- **Jest Documentation:** https://jestjs.io/
|
||||
- **React Testing Library:** https://testing-library.com/react
|
||||
- **Cypress Documentation:** https://docs.cypress.io/
|
||||
- **Detox Documentation:** https://wix.github.io/Detox/
|
||||
|
||||
---
|
||||
|
||||
## 📊 Test Metrics
|
||||
|
||||
| Metric | Target | Current |
|
||||
|--------|--------|---------|
|
||||
| Unit Test Coverage | 80% | 70% |
|
||||
| Integration Test Coverage | 60% | 60% |
|
||||
| E2E Test Coverage | 50% | 40% |
|
||||
| Test Execution Time (Unit) | < 2 min | ~1.5 min |
|
||||
| Test Execution Time (E2E) | < 10 min | ~8 min |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Roadmap
|
||||
|
||||
- [ ] Achieve 80% unit test coverage
|
||||
- [ ] Add visual regression testing (Percy/Chromatic)
|
||||
- [ ] Implement mutation testing (Stryker)
|
||||
- [ ] Add performance testing (Lighthouse CI)
|
||||
- [ ] Set up continuous test monitoring (Codecov)
|
||||
- [ ] Create test data factories for all pallets
|
||||
- [ ] Add snapshot testing for UI components
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-21
|
||||
**Test Suite Version:** 1.0.0
|
||||
**Maintained By:** PezkuwiChain Development Team
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Detox E2E Test: Education Platform Flow (Mobile)
|
||||
* Based on pallet-perwerde integration tests
|
||||
*
|
||||
* Flow:
|
||||
* 1. Browse Courses → 2. Enroll → 3. Complete Course → 4. Earn Certificate
|
||||
*/
|
||||
|
||||
describe('Education Platform Flow (Mobile E2E)', () => {
|
||||
const testUser = {
|
||||
address: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||
name: 'Test Student',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({
|
||||
newInstance: true,
|
||||
permissions: { notifications: 'YES' },
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
|
||||
// Navigate to Education tab
|
||||
await element(by.id('bottom-tab-education')).tap();
|
||||
});
|
||||
|
||||
describe('Course Browsing', () => {
|
||||
it('should display list of available courses', async () => {
|
||||
// Wait for courses to load
|
||||
await waitFor(element(by.id('course-list')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
|
||||
// Should have at least one course
|
||||
await expect(element(by.id('course-card-0'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show course details on tap', async () => {
|
||||
await element(by.id('course-card-0')).tap();
|
||||
|
||||
// Should show course detail screen
|
||||
await expect(element(by.id('course-detail-screen'))).toBeVisible();
|
||||
|
||||
// Should display course information
|
||||
await expect(element(by.id('course-title'))).toBeVisible();
|
||||
await expect(element(by.id('course-description'))).toBeVisible();
|
||||
await expect(element(by.id('course-content-url'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should filter courses by status', async () => {
|
||||
// Tap Active filter
|
||||
await element(by.text('Active')).tap();
|
||||
|
||||
// Should show only active courses
|
||||
await expect(element(by.id('course-status-active'))).toBeVisible();
|
||||
|
||||
// Tap Archived filter
|
||||
await element(by.text('Archived')).tap();
|
||||
|
||||
// Should show archived courses
|
||||
await expect(element(by.id('course-status-archived'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show empty state when no courses', async () => {
|
||||
// Mock empty course list
|
||||
await device.setURLBlacklist(['.*courses.*']);
|
||||
|
||||
await element(by.id('refresh-courses')).swipe('down');
|
||||
|
||||
await expect(element(by.text('No courses available yet'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Enrollment', () => {
|
||||
it('should enroll in an active course', async () => {
|
||||
// Open course detail
|
||||
await element(by.id('course-card-0')).tap();
|
||||
|
||||
// Tap enroll button
|
||||
await element(by.id('enroll-button')).tap();
|
||||
|
||||
// Confirm enrollment
|
||||
await element(by.text('Confirm')).tap();
|
||||
|
||||
// Wait for transaction
|
||||
await waitFor(element(by.text('Enrolled successfully')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
|
||||
// Button should change to "Continue Learning"
|
||||
await expect(element(by.text('Continue Learning'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show "Already Enrolled" state', async () => {
|
||||
// Mock enrolled state
|
||||
await element(by.id('course-card-0')).tap();
|
||||
|
||||
// If already enrolled, should show different button
|
||||
await expect(element(by.text('Enrolled'))).toBeVisible();
|
||||
await expect(element(by.text('Continue Learning'))).toBeVisible();
|
||||
|
||||
// Enroll button should not be visible
|
||||
await expect(element(by.text('Enroll'))).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('should disable enroll for archived courses', async () => {
|
||||
// Filter to archived courses
|
||||
await element(by.text('Archived')).tap();
|
||||
|
||||
await element(by.id('course-card-0')).tap();
|
||||
|
||||
// Enroll button should be disabled or show "Archived"
|
||||
await expect(element(by.id('enroll-button'))).not.toBeVisible();
|
||||
await expect(element(by.text('Archived'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show enrolled courses count', async () => {
|
||||
// Navigate to profile or "My Courses"
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
|
||||
// Should show count
|
||||
await expect(element(by.text('Enrolled: 3/100 courses'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should warn when approaching course limit', async () => {
|
||||
// Mock 98 enrolled courses
|
||||
// (In real app, this would be fetched from blockchain)
|
||||
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
|
||||
await expect(element(by.text('Enrolled: 98/100 courses'))).toBeVisible();
|
||||
await expect(element(by.text(/can enroll in 2 more/i))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Completion', () => {
|
||||
it('should complete an enrolled course', async () => {
|
||||
// Navigate to enrolled course
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
await element(by.id('enrolled-course-0')).tap();
|
||||
|
||||
// Tap complete button
|
||||
await element(by.id('complete-course-button')).tap();
|
||||
|
||||
// Enter completion points (e.g., quiz score)
|
||||
await element(by.id('points-input')).typeText('85');
|
||||
|
||||
// Submit completion
|
||||
await element(by.text('Submit')).tap();
|
||||
|
||||
// Wait for transaction
|
||||
await waitFor(element(by.text('Course completed!')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
|
||||
// Should show completion badge
|
||||
await expect(element(by.text('Completed'))).toBeVisible();
|
||||
await expect(element(by.text('85 points earned'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show certificate for completed course', async () => {
|
||||
// Open completed course
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
await element(by.id('completed-course-0')).tap();
|
||||
|
||||
// Should show certificate button
|
||||
await expect(element(by.id('view-certificate-button'))).toBeVisible();
|
||||
|
||||
await element(by.id('view-certificate-button')).tap();
|
||||
|
||||
// Certificate modal should open
|
||||
await expect(element(by.id('certificate-modal'))).toBeVisible();
|
||||
await expect(element(by.text(testUser.name))).toBeVisible();
|
||||
await expect(element(by.text(/certificate of completion/i))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should prevent completing course twice', async () => {
|
||||
// Open already completed course
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
await element(by.id('completed-course-0')).tap();
|
||||
|
||||
// Complete button should not be visible
|
||||
await expect(element(by.id('complete-course-button'))).not.toBeVisible();
|
||||
|
||||
// Should show "Completed" status
|
||||
await expect(element(by.text('Completed'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should prevent completing without enrollment', async () => {
|
||||
// Browse courses (not enrolled)
|
||||
await element(by.text('All Courses')).tap();
|
||||
await element(by.id('course-card-5')).tap(); // Unenrolled course
|
||||
|
||||
// Complete button should not be visible
|
||||
await expect(element(by.id('complete-course-button'))).not.toBeVisible();
|
||||
|
||||
// Only enroll button should be visible
|
||||
await expect(element(by.id('enroll-button'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Progress Tracking', () => {
|
||||
it('should show progress for enrolled courses', async () => {
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
|
||||
// Should show progress indicator
|
||||
await expect(element(by.id('course-progress-0'))).toBeVisible();
|
||||
|
||||
// Progress should be between 0-100%
|
||||
// (Mock or check actual progress value)
|
||||
});
|
||||
|
||||
it('should update progress as lessons are completed', async () => {
|
||||
// This would require lesson-by-lesson tracking
|
||||
// For now, test that progress exists
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
await element(by.id('enrolled-course-0')).tap();
|
||||
|
||||
await expect(element(by.text(/In Progress/i))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pull to Refresh', () => {
|
||||
it('should refresh course list on pull down', async () => {
|
||||
// Initial course count
|
||||
const initialCourses = await element(by.id('course-card-0')).getAttributes();
|
||||
|
||||
// Pull to refresh
|
||||
await element(by.id('course-list')).swipe('down', 'fast', 0.8);
|
||||
|
||||
// Wait for refresh to complete
|
||||
await waitFor(element(by.id('course-card-0')))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
|
||||
// Courses should be reloaded
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin: Create Course', () => {
|
||||
it('should create a new course as admin', async () => {
|
||||
// Mock admin role
|
||||
// (In real app, check if user has admin rights)
|
||||
|
||||
// Tap FAB to create course
|
||||
await element(by.id('create-course-fab')).tap();
|
||||
|
||||
// Fill course creation form
|
||||
await element(by.id('course-title-input')).typeText('New Blockchain Course');
|
||||
await element(by.id('course-description-input')).typeText(
|
||||
'Learn about blockchain technology'
|
||||
);
|
||||
await element(by.id('course-content-url-input')).typeText(
|
||||
'https://example.com/course'
|
||||
);
|
||||
|
||||
// Submit
|
||||
await element(by.id('submit-course-button')).tap();
|
||||
|
||||
// Wait for transaction
|
||||
await waitFor(element(by.text('Course created successfully')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
|
||||
// New course should appear in list
|
||||
await expect(element(by.text('New Blockchain Course'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should prevent non-admins from creating courses', async () => {
|
||||
// Mock non-admin user
|
||||
// Create course FAB should not be visible
|
||||
await expect(element(by.id('create-course-fab'))).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin: Archive Course', () => {
|
||||
it('should archive a course as owner', async () => {
|
||||
// Open course owned by user
|
||||
await element(by.id('my-created-courses-tab')).tap();
|
||||
await element(by.id('owned-course-0')).tap();
|
||||
|
||||
// Open course menu
|
||||
await element(by.id('course-menu-button')).tap();
|
||||
|
||||
// Tap archive
|
||||
await element(by.text('Archive Course')).tap();
|
||||
|
||||
// Confirm
|
||||
await element(by.text('Confirm')).tap();
|
||||
|
||||
// Wait for transaction
|
||||
await waitFor(element(by.text('Course archived')))
|
||||
.toBeVisible()
|
||||
.withTimeout(10000);
|
||||
|
||||
// Course status should change to Archived
|
||||
await expect(element(by.text('Archived'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Categories', () => {
|
||||
it('should filter courses by category', async () => {
|
||||
// Tap category filter
|
||||
await element(by.id('category-filter-blockchain')).tap();
|
||||
|
||||
// Should show only blockchain courses
|
||||
await expect(element(by.text('Blockchain'))).toBeVisible();
|
||||
|
||||
// Other categories should be filtered out
|
||||
});
|
||||
|
||||
it('should show category badges on course cards', async () => {
|
||||
await expect(element(by.id('course-category-badge-0'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle enrollment failure gracefully', async () => {
|
||||
// Mock API failure
|
||||
await device.setURLBlacklist(['.*enroll.*']);
|
||||
|
||||
await element(by.id('course-card-0')).tap();
|
||||
await element(by.id('enroll-button')).tap();
|
||||
await element(by.text('Confirm')).tap();
|
||||
|
||||
// Should show error message
|
||||
await waitFor(element(by.text(/failed to enroll/i)))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
|
||||
// Retry button should be available
|
||||
await expect(element(by.text('Retry'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('should handle completion failure gracefully', async () => {
|
||||
await element(by.id('my-courses-tab')).tap();
|
||||
await element(by.id('enrolled-course-0')).tap();
|
||||
await element(by.id('complete-course-button')).tap();
|
||||
|
||||
// Mock transaction failure
|
||||
await device.setURLBlacklist(['.*complete.*']);
|
||||
|
||||
await element(by.id('points-input')).typeText('85');
|
||||
await element(by.text('Submit')).tap();
|
||||
|
||||
// Should show error
|
||||
await waitFor(element(by.text(/failed to complete/i)))
|
||||
.toBeVisible()
|
||||
.withTimeout(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Course List Component Tests (Mobile)
|
||||
* Based on pallet-perwerde tests
|
||||
*
|
||||
* Tests cover:
|
||||
* - create_course_works
|
||||
* - enroll_works
|
||||
* - enroll_fails_for_archived_course
|
||||
* - enroll_fails_if_already_enrolled
|
||||
* - complete_course_works
|
||||
* - multiple_students_can_enroll_same_course
|
||||
*/
|
||||
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import {
|
||||
generateMockCourse,
|
||||
generateMockCourseList,
|
||||
generateMockEnrollment,
|
||||
} from '../../../utils/mockDataGenerators';
|
||||
import { buildPolkadotContextState } from '../../../utils/testHelpers';
|
||||
|
||||
// Mock the Course List component (adjust path as needed)
|
||||
// import { CourseList } from '@/src/components/perwerde/CourseList';
|
||||
|
||||
describe('Course List Component (Mobile)', () => {
|
||||
let mockApi: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApi = {
|
||||
query: {
|
||||
perwerde: {
|
||||
courses: jest.fn(),
|
||||
enrollments: jest.fn(),
|
||||
courseCount: jest.fn(() => ({
|
||||
toNumber: () => 5,
|
||||
})),
|
||||
},
|
||||
},
|
||||
tx: {
|
||||
perwerde: {
|
||||
enrollInCourse: jest.fn(() => ({
|
||||
signAndSend: jest.fn((account, callback) => {
|
||||
callback({ status: { isInBlock: true } });
|
||||
return Promise.resolve('0x123');
|
||||
}),
|
||||
})),
|
||||
completeCourse: jest.fn(() => ({
|
||||
signAndSend: jest.fn((account, callback) => {
|
||||
callback({ status: { isInBlock: true } });
|
||||
return Promise.resolve('0x123');
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Course Display', () => {
|
||||
test('should render list of active courses', () => {
|
||||
const courses = generateMockCourseList(5, 3); // 5 total, 3 active
|
||||
|
||||
// Mock component rendering
|
||||
// const { getAllByTestId } = render(<CourseList courses={courses} />);
|
||||
// const courseCards = getAllByTestId('course-card');
|
||||
// expect(courseCards).toHaveLength(5);
|
||||
|
||||
const activeCourses = courses.filter(c => c.status === 'Active');
|
||||
expect(activeCourses).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('should display course status badges', () => {
|
||||
const activeCourse = generateMockCourse('Active');
|
||||
const archivedCourse = generateMockCourse('Archived');
|
||||
|
||||
// Component should show:
|
||||
// - Active course: Green "Active" badge
|
||||
// - Archived course: Gray "Archived" badge
|
||||
});
|
||||
|
||||
test('should show course details (title, description, content URL)', () => {
|
||||
const course = generateMockCourse('Active', 0);
|
||||
|
||||
expect(course).toHaveProperty('title');
|
||||
expect(course).toHaveProperty('description');
|
||||
expect(course).toHaveProperty('contentUrl');
|
||||
|
||||
// Component should display all these fields
|
||||
});
|
||||
|
||||
test('should filter courses by status', () => {
|
||||
const courses = generateMockCourseList(10, 6);
|
||||
|
||||
const activeCourses = courses.filter(c => c.status === 'Active');
|
||||
const archivedCourses = courses.filter(c => c.status === 'Archived');
|
||||
|
||||
expect(activeCourses).toHaveLength(6);
|
||||
expect(archivedCourses).toHaveLength(4);
|
||||
|
||||
// Component should have filter toggle:
|
||||
// [All] [Active] [Archived]
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Enrollment', () => {
|
||||
test('should show enroll button for active courses', () => {
|
||||
const activeCourse = generateMockCourse('Active');
|
||||
|
||||
// Component should show "Enroll" button
|
||||
// Button should be enabled
|
||||
});
|
||||
|
||||
test('should disable enroll button for archived courses', () => {
|
||||
// Test: enroll_fails_for_archived_course
|
||||
const archivedCourse = generateMockCourse('Archived');
|
||||
|
||||
// Component should show "Archived" or disabled "Enroll" button
|
||||
});
|
||||
|
||||
test('should show "Already Enrolled" state', () => {
|
||||
// Test: enroll_fails_if_already_enrolled
|
||||
const course = generateMockCourse('Active', 0);
|
||||
const enrollment = generateMockEnrollment(0, false);
|
||||
|
||||
mockApi.query.perwerde.enrollments.mockResolvedValue({
|
||||
unwrap: () => enrollment,
|
||||
});
|
||||
|
||||
// Component should show "Enrolled" badge or "Continue Learning" button
|
||||
// instead of "Enroll" button
|
||||
});
|
||||
|
||||
test('should successfully enroll in course', async () => {
|
||||
// Test: enroll_works
|
||||
const course = generateMockCourse('Active', 0);
|
||||
|
||||
mockApi.query.perwerde.enrollments.mockResolvedValue({
|
||||
unwrap: () => null, // Not enrolled yet
|
||||
});
|
||||
|
||||
const tx = mockApi.tx.perwerde.enrollInCourse(course.courseId);
|
||||
|
||||
await expect(tx.signAndSend('address', jest.fn())).resolves.toBe('0x123');
|
||||
|
||||
expect(mockApi.tx.perwerde.enrollInCourse).toHaveBeenCalledWith(course.courseId);
|
||||
});
|
||||
|
||||
test('should support multiple students enrolling in same course', () => {
|
||||
// Test: multiple_students_can_enroll_same_course
|
||||
const course = generateMockCourse('Active', 0);
|
||||
const student1 = '5Student1...';
|
||||
const student2 = '5Student2...';
|
||||
|
||||
const enrollment1 = generateMockEnrollment(0);
|
||||
const enrollment2 = generateMockEnrollment(0);
|
||||
|
||||
expect(enrollment1.student).not.toBe(enrollment2.student);
|
||||
// Both can enroll independently
|
||||
});
|
||||
|
||||
test('should show enrolled courses count (max 100)', () => {
|
||||
// Test: enroll_fails_when_too_many_courses
|
||||
const maxCourses = 100;
|
||||
const currentEnrollments = 98;
|
||||
|
||||
// Component should show: "Enrolled: 98/100 courses"
|
||||
// Warning when approaching limit: "You can enroll in 2 more courses"
|
||||
|
||||
expect(currentEnrollments).toBeLessThan(maxCourses);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Completion', () => {
|
||||
test('should show completion button for enrolled students', () => {
|
||||
const enrollment = generateMockEnrollment(0, false);
|
||||
|
||||
// Component should show:
|
||||
// - "Complete Course" button
|
||||
// - Progress indicator
|
||||
});
|
||||
|
||||
test('should successfully complete course with points', async () => {
|
||||
// Test: complete_course_works
|
||||
const course = generateMockCourse('Active', 0);
|
||||
const pointsEarned = 85;
|
||||
|
||||
const tx = mockApi.tx.perwerde.completeCourse(course.courseId, pointsEarned);
|
||||
|
||||
await expect(tx.signAndSend('address', jest.fn())).resolves.toBe('0x123');
|
||||
|
||||
expect(mockApi.tx.perwerde.completeCourse).toHaveBeenCalledWith(
|
||||
course.courseId,
|
||||
pointsEarned
|
||||
);
|
||||
});
|
||||
|
||||
test('should show completed course with certificate', () => {
|
||||
const completedEnrollment = generateMockEnrollment(0, true);
|
||||
|
||||
// Component should display:
|
||||
// - "Completed" badge (green)
|
||||
// - Points earned: "85 points"
|
||||
// - "View Certificate" button
|
||||
// - Completion date
|
||||
});
|
||||
|
||||
test('should prevent completing course twice', () => {
|
||||
// Test: complete_course_fails_if_already_completed
|
||||
const completedEnrollment = generateMockEnrollment(0, true);
|
||||
|
||||
mockApi.query.perwerde.enrollments.mockResolvedValue({
|
||||
unwrap: () => completedEnrollment,
|
||||
});
|
||||
|
||||
// "Complete Course" button should be hidden or disabled
|
||||
});
|
||||
|
||||
test('should prevent completing without enrollment', () => {
|
||||
// Test: complete_course_fails_without_enrollment
|
||||
mockApi.query.perwerde.enrollments.mockResolvedValue({
|
||||
unwrap: () => null,
|
||||
});
|
||||
|
||||
// "Complete Course" button should not appear
|
||||
// Only "Enroll" button should be visible
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Categories', () => {
|
||||
test('should categorize courses (Blockchain, Programming, Kurdistan Culture)', () => {
|
||||
// Courses can have categories
|
||||
const categories = ['Blockchain', 'Programming', 'Kurdistan Culture', 'History'];
|
||||
|
||||
// Component should show category filter pills
|
||||
});
|
||||
|
||||
test('should filter courses by category', () => {
|
||||
const courses = generateMockCourseList(10, 8);
|
||||
|
||||
// Mock categories
|
||||
courses.forEach((course, index) => {
|
||||
(course as any).category = ['Blockchain', 'Programming', 'Culture'][index % 3];
|
||||
});
|
||||
|
||||
const blockchainCourses = courses.filter((c: any) => c.category === 'Blockchain');
|
||||
|
||||
// Component should filter when category pill is tapped
|
||||
});
|
||||
});
|
||||
|
||||
describe('Course Progress', () => {
|
||||
test('should show enrollment progress (enrolled but not completed)', () => {
|
||||
const enrollment = generateMockEnrollment(0, false);
|
||||
|
||||
// Component should show:
|
||||
// - "In Progress" badge
|
||||
// - Start date
|
||||
// - "Continue Learning" button
|
||||
});
|
||||
|
||||
test('should track completion percentage if available', () => {
|
||||
// Future feature: track lesson completion percentage
|
||||
const progressPercentage = 67; // 67% complete
|
||||
|
||||
// Component should show progress bar: 67%
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Features', () => {
|
||||
test('should show create course button for admins', () => {
|
||||
// Test: create_course_works
|
||||
const isAdmin = true;
|
||||
|
||||
if (isAdmin) {
|
||||
// Component should show "+ Create Course" FAB
|
||||
}
|
||||
});
|
||||
|
||||
test('should show archive course button for course owners', () => {
|
||||
// Test: archive_course_works
|
||||
const course = generateMockCourse('Active', 0);
|
||||
const isOwner = true;
|
||||
|
||||
if (isOwner) {
|
||||
// Component should show "Archive" button in course menu
|
||||
}
|
||||
});
|
||||
|
||||
test('should prevent non-admins from creating courses', () => {
|
||||
// Test: create_course_fails_for_non_admin
|
||||
const isAdmin = false;
|
||||
|
||||
if (!isAdmin) {
|
||||
// "Create Course" button should not be visible
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pull to Refresh', () => {
|
||||
test('should refresh course list on pull down', async () => {
|
||||
const initialCourses = generateMockCourseList(5, 3);
|
||||
|
||||
// Simulate pull-to-refresh
|
||||
// const { getByTestId } = render(<CourseList />);
|
||||
// fireEvent(getByTestId('course-list'), 'refresh');
|
||||
|
||||
// await waitFor(() => {
|
||||
// expect(mockApi.query.perwerde.courses).toHaveBeenCalledTimes(2);
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty States', () => {
|
||||
test('should show empty state when no courses exist', () => {
|
||||
const emptyCourses: any[] = [];
|
||||
|
||||
// Component should display:
|
||||
// - Icon (📚)
|
||||
// - Message: "No courses available yet"
|
||||
// - Subtext: "Check back later for new courses"
|
||||
});
|
||||
|
||||
test('should show empty state when no active courses', () => {
|
||||
const courses = generateMockCourseList(5, 0); // All archived
|
||||
|
||||
const activeCourses = courses.filter(c => c.status === 'Active');
|
||||
expect(activeCourses).toHaveLength(0);
|
||||
|
||||
// Component should display:
|
||||
// - Message: "No active courses"
|
||||
// - Button: "Show Archived Courses"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TEST DATA FIXTURES
|
||||
*/
|
||||
export const educationTestFixtures = {
|
||||
activeCourse: generateMockCourse('Active', 0),
|
||||
archivedCourse: generateMockCourse('Archived', 1),
|
||||
courseList: generateMockCourseList(10, 7),
|
||||
pendingEnrollment: generateMockEnrollment(0, false),
|
||||
completedEnrollment: generateMockEnrollment(0, true),
|
||||
categories: ['Blockchain', 'Programming', 'Kurdistan Culture', 'History', 'Languages'],
|
||||
};
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Mobile Frontend Test Runner
|
||||
# Runs Jest unit tests and Detox E2E tests for mobile application
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ PezkuwiChain Mobile Frontend Test Suite ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Navigate to mobile directory
|
||||
cd "$(dirname "$0")/../mobile"
|
||||
|
||||
# Function to run unit tests
|
||||
run_unit_tests() {
|
||||
echo -e "${YELLOW}Running Unit Tests (Jest + React Native Testing Library)...${NC}"
|
||||
echo ""
|
||||
|
||||
npm run test -- \
|
||||
--testPathPattern="tests/mobile/unit" \
|
||||
--coverage \
|
||||
--verbose
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Unit tests passed!${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Unit tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run E2E tests
|
||||
run_e2e_tests() {
|
||||
PLATFORM=${1:-ios} # Default to iOS
|
||||
|
||||
echo -e "${YELLOW}Running E2E Tests (Detox) on ${PLATFORM}...${NC}"
|
||||
echo ""
|
||||
|
||||
# Build the app for testing
|
||||
echo "Building app for testing..."
|
||||
if [ "$PLATFORM" == "ios" ]; then
|
||||
detox build --configuration ios.sim.debug
|
||||
else
|
||||
detox build --configuration android.emu.debug
|
||||
fi
|
||||
|
||||
# Run Detox tests
|
||||
if [ "$PLATFORM" == "ios" ]; then
|
||||
detox test --configuration ios.sim.debug
|
||||
else
|
||||
detox test --configuration android.emu.debug
|
||||
fi
|
||||
|
||||
DETOX_EXIT_CODE=$?
|
||||
|
||||
if [ $DETOX_EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ E2E tests passed!${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ E2E tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run specific test suite
|
||||
run_specific_suite() {
|
||||
echo -e "${YELLOW}Running specific test suite: $1${NC}"
|
||||
echo ""
|
||||
|
||||
case $1 in
|
||||
citizenship)
|
||||
npm run test -- --testPathPattern="citizenship"
|
||||
;;
|
||||
education)
|
||||
npm run test -- --testPathPattern="education"
|
||||
;;
|
||||
governance)
|
||||
npm run test -- --testPathPattern="governance"
|
||||
;;
|
||||
p2p)
|
||||
npm run test -- --testPathPattern="p2p"
|
||||
;;
|
||||
rewards)
|
||||
npm run test -- --testPathPattern="rewards"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown test suite: $1${NC}"
|
||||
echo "Available suites: citizenship, education, governance, p2p, rewards"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
if [ $# -eq 0 ]; then
|
||||
# No arguments: run all tests
|
||||
echo -e "${BLUE}Running all tests...${NC}"
|
||||
echo ""
|
||||
run_unit_tests
|
||||
echo ""
|
||||
echo -e "${YELLOW}E2E tests require platform selection. Use: $0 e2e <ios|android>${NC}"
|
||||
elif [ "$1" == "unit" ]; then
|
||||
run_unit_tests
|
||||
elif [ "$1" == "e2e" ]; then
|
||||
PLATFORM=${2:-ios}
|
||||
run_e2e_tests "$PLATFORM"
|
||||
elif [ "$1" == "suite" ] && [ -n "$2" ]; then
|
||||
run_specific_suite "$2"
|
||||
elif [ "$1" == "watch" ]; then
|
||||
echo -e "${YELLOW}Running tests in watch mode...${NC}"
|
||||
npm run test -- --watch
|
||||
elif [ "$1" == "coverage" ]; then
|
||||
echo -e "${YELLOW}Running tests with coverage report...${NC}"
|
||||
npm run test -- --coverage --coverageReporters=html
|
||||
echo ""
|
||||
echo -e "${GREEN}Coverage report generated at: mobile/coverage/index.html${NC}"
|
||||
else
|
||||
echo -e "${RED}Usage:${NC}"
|
||||
echo " $0 # Run unit tests only"
|
||||
echo " $0 unit # Run only unit tests"
|
||||
echo " $0 e2e <ios|android> # Run E2E tests on platform"
|
||||
echo " $0 suite <name> # Run specific test suite"
|
||||
echo " $0 watch # Run tests in watch mode"
|
||||
echo " $0 coverage # Run tests with coverage report"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ All tests completed successfully! ✓ ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# Web Frontend Test Runner
|
||||
# Runs Jest unit tests and Cypress E2E tests for web application
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ PezkuwiChain Web Frontend Test Suite ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Navigate to web directory
|
||||
cd "$(dirname "$0")/../web"
|
||||
|
||||
# Function to run unit tests
|
||||
run_unit_tests() {
|
||||
echo -e "${YELLOW}Running Unit Tests (Jest + React Testing Library)...${NC}"
|
||||
echo ""
|
||||
|
||||
npm run test -- \
|
||||
--testPathPattern="tests/web/unit" \
|
||||
--coverage \
|
||||
--verbose
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Unit tests passed!${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Unit tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run E2E tests
|
||||
run_e2e_tests() {
|
||||
echo -e "${YELLOW}Running E2E Tests (Cypress)...${NC}"
|
||||
echo ""
|
||||
|
||||
# Start dev server in background
|
||||
echo "Starting development server..."
|
||||
npm run dev > /dev/null 2>&1 &
|
||||
DEV_SERVER_PID=$!
|
||||
|
||||
# Wait for server to be ready
|
||||
echo "Waiting for server to start..."
|
||||
sleep 5
|
||||
|
||||
# Run Cypress tests
|
||||
npx cypress run --spec "../tests/web/e2e/cypress/**/*.cy.ts"
|
||||
|
||||
CYPRESS_EXIT_CODE=$?
|
||||
|
||||
# Kill dev server
|
||||
kill $DEV_SERVER_PID
|
||||
|
||||
if [ $CYPRESS_EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ E2E tests passed!${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ E2E tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run specific test suite
|
||||
run_specific_suite() {
|
||||
echo -e "${YELLOW}Running specific test suite: $1${NC}"
|
||||
echo ""
|
||||
|
||||
case $1 in
|
||||
citizenship)
|
||||
npm run test -- --testPathPattern="citizenship"
|
||||
;;
|
||||
education)
|
||||
npm run test -- --testPathPattern="education"
|
||||
;;
|
||||
governance)
|
||||
npm run test -- --testPathPattern="governance"
|
||||
;;
|
||||
p2p)
|
||||
npm run test -- --testPathPattern="p2p"
|
||||
;;
|
||||
rewards)
|
||||
npm run test -- --testPathPattern="rewards"
|
||||
;;
|
||||
treasury)
|
||||
npm run test -- --testPathPattern="treasury"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown test suite: $1${NC}"
|
||||
echo "Available suites: citizenship, education, governance, p2p, rewards, treasury"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
if [ $# -eq 0 ]; then
|
||||
# No arguments: run all tests
|
||||
echo -e "${BLUE}Running all tests...${NC}"
|
||||
echo ""
|
||||
run_unit_tests
|
||||
echo ""
|
||||
run_e2e_tests
|
||||
elif [ "$1" == "unit" ]; then
|
||||
run_unit_tests
|
||||
elif [ "$1" == "e2e" ]; then
|
||||
run_e2e_tests
|
||||
elif [ "$1" == "suite" ] && [ -n "$2" ]; then
|
||||
run_specific_suite "$2"
|
||||
elif [ "$1" == "watch" ]; then
|
||||
echo -e "${YELLOW}Running tests in watch mode...${NC}"
|
||||
npm run test -- --watch
|
||||
elif [ "$1" == "coverage" ]; then
|
||||
echo -e "${YELLOW}Running tests with coverage report...${NC}"
|
||||
npm run test -- --coverage --coverageReporters=html
|
||||
echo ""
|
||||
echo -e "${GREEN}Coverage report generated at: web/coverage/index.html${NC}"
|
||||
else
|
||||
echo -e "${RED}Usage:${NC}"
|
||||
echo " $0 # Run all tests"
|
||||
echo " $0 unit # Run only unit tests"
|
||||
echo " $0 e2e # Run only E2E tests"
|
||||
echo " $0 suite <name> # Run specific test suite"
|
||||
echo " $0 watch # Run tests in watch mode"
|
||||
echo " $0 coverage # Run tests with coverage report"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ All tests completed successfully! ✓ ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Mock Data Generators
|
||||
* Based on blockchain pallet test scenarios
|
||||
* Generates consistent test data matching on-chain state
|
||||
*/
|
||||
|
||||
export type KYCStatus = 'NotStarted' | 'Pending' | 'Approved' | 'Rejected';
|
||||
export type CourseStatus = 'Active' | 'Archived';
|
||||
export type ElectionType = 'Presidential' | 'Parliamentary' | 'ConstitutionalCourt' | 'SpeakerElection';
|
||||
export type ElectionStatus = 'CandidacyPeriod' | 'CampaignPeriod' | 'VotingPeriod' | 'Finalization';
|
||||
export type ProposalStatus = 'Pending' | 'Voting' | 'Approved' | 'Rejected' | 'Executed';
|
||||
|
||||
// ============================================================================
|
||||
// 1. IDENTITY & KYC
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockIdentity = (name?: string, email?: string) => ({
|
||||
name: name || 'Pezkuwi User',
|
||||
email: email || `user${Math.floor(Math.random() * 1000)}@pezkuwi.com`,
|
||||
});
|
||||
|
||||
export const generateMockKYCApplication = (status: KYCStatus = 'Pending') => ({
|
||||
cids: ['QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG'],
|
||||
notes: 'My KYC documents',
|
||||
depositAmount: 10_000_000_000_000n, // 10 HEZ
|
||||
status,
|
||||
appliedAt: Date.now(),
|
||||
});
|
||||
|
||||
export const generateMockCitizenNFT = (id: number = 0) => ({
|
||||
nftId: id,
|
||||
owner: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
welatiRole: true,
|
||||
mintedAt: Date.now(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 2. EDUCATION (PERWERDE)
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockCourse = (status: CourseStatus = 'Active', id?: number) => ({
|
||||
courseId: id ?? Math.floor(Math.random() * 1000),
|
||||
title: `Course ${id ?? Math.floor(Math.random() * 100)}`,
|
||||
description: 'Learn about blockchain technology and Digital Kurdistan',
|
||||
contentUrl: 'https://example.com/course',
|
||||
status,
|
||||
owner: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
export const generateMockEnrollment = (courseId: number, completed: boolean = false) => ({
|
||||
student: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
courseId,
|
||||
enrolledAt: Date.now(),
|
||||
completedAt: completed ? Date.now() + 86400000 : null,
|
||||
pointsEarned: completed ? Math.floor(Math.random() * 100) : 0,
|
||||
});
|
||||
|
||||
export const generateMockCourseList = (count: number = 5, activeCount: number = 3) => {
|
||||
const courses = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
courses.push(generateMockCourse(i < activeCount ? 'Active' : 'Archived', i));
|
||||
}
|
||||
return courses;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 3. GOVERNANCE (WELATI) - ELECTIONS
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockElection = (
|
||||
type: ElectionType = 'Presidential',
|
||||
status: ElectionStatus = 'VotingPeriod'
|
||||
) => ({
|
||||
electionId: Math.floor(Math.random() * 100),
|
||||
electionType: type,
|
||||
status,
|
||||
startBlock: 1,
|
||||
endBlock: 777601,
|
||||
candidacyPeriodEnd: 86401,
|
||||
campaignPeriodEnd: 345601,
|
||||
votingPeriodEnd: 777601,
|
||||
candidates: generateMockCandidates(type === 'Presidential' ? 2 : 5),
|
||||
totalVotes: Math.floor(Math.random() * 10000),
|
||||
turnoutPercentage: 45.2,
|
||||
requiredTurnout: type === 'Presidential' ? 50 : 40,
|
||||
});
|
||||
|
||||
export const generateMockCandidate = (electionType: ElectionType) => ({
|
||||
candidateId: Math.floor(Math.random() * 1000),
|
||||
address: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
name: `Candidate ${Math.floor(Math.random() * 100)}`,
|
||||
party: electionType === 'Parliamentary' ? ['Green Party', 'Democratic Alliance', 'Independent'][Math.floor(Math.random() * 3)] : undefined,
|
||||
endorsements: electionType === 'Presidential' ? 100 + Math.floor(Math.random() * 50) : 50 + Math.floor(Math.random() * 30),
|
||||
votes: Math.floor(Math.random() * 5000),
|
||||
percentage: Math.random() * 100,
|
||||
trustScore: 600 + Math.floor(Math.random() * 200),
|
||||
});
|
||||
|
||||
export const generateMockCandidates = (count: number = 3) => {
|
||||
return Array.from({ length: count }, () => generateMockCandidate('Presidential'));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 4. GOVERNANCE - PROPOSALS
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockProposal = (status: ProposalStatus = 'Voting') => ({
|
||||
proposalId: Math.floor(Math.random() * 1000),
|
||||
index: Math.floor(Math.random() * 100),
|
||||
proposer: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
title: 'Budget Amendment',
|
||||
description: 'Increase education budget by 10%',
|
||||
value: '10000000000000000', // 10,000 HEZ
|
||||
beneficiary: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
bond: '1000000000000000', // 1,000 HEZ
|
||||
ayes: Math.floor(Math.random() * 1000),
|
||||
nays: Math.floor(Math.random() * 200),
|
||||
status: status === 'Voting' ? 'active' as const : status.toLowerCase() as any,
|
||||
endBlock: 1000000,
|
||||
priority: ['Low', 'Medium', 'High'][Math.floor(Math.random() * 3)],
|
||||
decisionType: 'ParliamentSimpleMajority',
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 5. P2P TRADING (WELATI)
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockP2POffer = () => ({
|
||||
offerId: Math.floor(Math.random() * 1000),
|
||||
creator: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
offerType: Math.random() > 0.5 ? 'Buy' as const : 'Sell' as const,
|
||||
cryptoAmount: Math.floor(Math.random() * 1000000000000000),
|
||||
fiatAmount: Math.floor(Math.random() * 10000),
|
||||
fiatCurrency: ['USD', 'EUR', 'TRY'][Math.floor(Math.random() * 3)],
|
||||
paymentMethod: 'Bank Transfer',
|
||||
minAmount: Math.floor(Math.random() * 1000),
|
||||
maxAmount: Math.floor(Math.random() * 5000) + 1000,
|
||||
trustLevel: Math.floor(Math.random() * 100),
|
||||
active: true,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
export const generateMockP2POffers = (count: number = 10) => {
|
||||
return Array.from({ length: count }, () => generateMockP2POffer());
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 6. REFERRAL SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockReferralInfo = (referralCount: number = 0) => {
|
||||
// Score tiers from tests
|
||||
let score: number;
|
||||
if (referralCount <= 10) {
|
||||
score = referralCount * 10;
|
||||
} else if (referralCount <= 50) {
|
||||
score = 100 + (referralCount - 10) * 5;
|
||||
} else if (referralCount <= 100) {
|
||||
score = 300 + (referralCount - 50) * 4;
|
||||
} else {
|
||||
score = 500; // capped
|
||||
}
|
||||
|
||||
return {
|
||||
referrer: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
referralCount,
|
||||
referralScore: score,
|
||||
pendingReferrals: Array.from({ length: Math.floor(Math.random() * 3) }, () =>
|
||||
`5${Math.random().toString(36).substring(2, 15)}`
|
||||
),
|
||||
confirmedReferrals: Array.from({ length: referralCount }, () =>
|
||||
`5${Math.random().toString(36).substring(2, 15)}`
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 7. STAKING & REWARDS
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockStakingScore = (stakeAmount: number = 500) => {
|
||||
// Amount tiers from tests
|
||||
let baseScore: number;
|
||||
const amountInHEZ = stakeAmount / 1_000_000_000_000;
|
||||
|
||||
if (amountInHEZ < 100) baseScore = 0;
|
||||
else if (amountInHEZ < 250) baseScore = 20;
|
||||
else if (amountInHEZ < 750) baseScore = 30;
|
||||
else baseScore = 40;
|
||||
|
||||
// Duration multipliers (mock 3 months = 1.4x)
|
||||
const durationMultiplier = 1.4;
|
||||
const finalScore = Math.min(baseScore * durationMultiplier, 100); // capped at 100
|
||||
|
||||
return {
|
||||
stakeAmount: BigInt(stakeAmount * 1_000_000_000_000),
|
||||
baseScore,
|
||||
trackingStartBlock: 100,
|
||||
currentBlock: 1396100, // 3 months later
|
||||
durationBlocks: 1296000, // 3 months
|
||||
durationMultiplier,
|
||||
finalScore: Math.floor(finalScore),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateMockEpochReward = (epochIndex: number = 0) => ({
|
||||
epochIndex,
|
||||
status: ['Open', 'ClaimPeriod', 'Closed'][Math.floor(Math.random() * 3)] as 'Open' | 'ClaimPeriod' | 'Closed',
|
||||
startBlock: epochIndex * 100 + 1,
|
||||
endBlock: (epochIndex + 1) * 100,
|
||||
totalRewardPool: 1000000000000000000n,
|
||||
totalTrustScore: 225,
|
||||
participantsCount: 3,
|
||||
claimDeadline: (epochIndex + 1) * 100 + 100,
|
||||
userTrustScore: 100,
|
||||
userReward: 444444444444444444n,
|
||||
claimed: false,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 8. TREASURY
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockTreasuryState = (period: number = 0) => {
|
||||
const baseMonthlyAmount = 50104166666666666666666n;
|
||||
const halvingFactor = BigInt(2 ** period);
|
||||
const monthlyAmount = baseMonthlyAmount / halvingFactor;
|
||||
|
||||
return {
|
||||
currentPeriod: period,
|
||||
monthlyAmount,
|
||||
totalReleased: 0n,
|
||||
nextReleaseMonth: 0,
|
||||
incentivePotBalance: 0n,
|
||||
governmentPotBalance: 0n,
|
||||
periodStartBlock: 1,
|
||||
blocksPerMonth: 432000,
|
||||
lastReleaseBlock: 0,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 9. TIKI (GOVERNANCE ROLES)
|
||||
// ============================================================================
|
||||
|
||||
export const TikiRoles = [
|
||||
'Welati', 'Parlementer', 'Serok', 'SerokiMeclise', 'Wezir',
|
||||
'Dadger', 'Dozger', 'Axa', 'Mamoste', 'Rewsenbîr'
|
||||
] as const;
|
||||
|
||||
export const TikiRoleScores: Record<string, number> = {
|
||||
Axa: 250,
|
||||
RêveberêProjeyê: 250,
|
||||
Serok: 200,
|
||||
ModeratorêCivakê: 200,
|
||||
EndameDiwane: 175,
|
||||
SerokiMeclise: 150,
|
||||
Dadger: 150,
|
||||
Dozger: 120,
|
||||
Wezir: 100,
|
||||
Parlementer: 100,
|
||||
Welati: 10,
|
||||
};
|
||||
|
||||
export const generateMockTikiData = (roles: string[] = ['Welati']) => {
|
||||
const totalScore = roles.reduce((sum, role) => sum + (TikiRoleScores[role] || 5), 0);
|
||||
|
||||
return {
|
||||
citizenNftId: Math.floor(Math.random() * 1000),
|
||||
roles,
|
||||
roleAssignments: roles.reduce((acc, role) => {
|
||||
acc[role] = role === 'Welati' ? 'Automatic' :
|
||||
role === 'Parlementer' || role === 'Serok' ? 'Elected' :
|
||||
role === 'Axa' || role === 'Mamoste' ? 'Earned' :
|
||||
'Appointed';
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
totalScore,
|
||||
scoreBreakdown: roles.reduce((acc, role) => {
|
||||
acc[role] = TikiRoleScores[role] || 5;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 10. TRUST SCORE
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockTrustScore = () => {
|
||||
const staking = 100;
|
||||
const referral = 50;
|
||||
const perwerde = 30;
|
||||
const tiki = 20;
|
||||
|
||||
// Formula: weighted_sum = (staking × 100) + (referral × 300) + (perwerde × 300) + (tiki × 300)
|
||||
// trust_score = staking × weighted_sum / 1000
|
||||
const weightedSum = (staking * 100) + (referral * 300) + (perwerde * 300) + (tiki * 300);
|
||||
const totalScore = (staking * weightedSum) / 1000;
|
||||
|
||||
return {
|
||||
totalScore,
|
||||
components: { staking, referral, perwerde, tiki },
|
||||
weights: {
|
||||
staking: 100,
|
||||
referral: 300,
|
||||
perwerde: 300,
|
||||
tiki: 300,
|
||||
},
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 11. VALIDATOR POOL
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockValidatorPool = () => ({
|
||||
poolSize: 15,
|
||||
currentEra: 5,
|
||||
eraStartBlock: 500,
|
||||
eraLength: 100,
|
||||
validatorSet: {
|
||||
stakeValidators: [1, 2, 3],
|
||||
parliamentaryValidators: [4, 5],
|
||||
meritValidators: [6, 7],
|
||||
totalCount: 7,
|
||||
},
|
||||
userMembership: {
|
||||
category: 'StakeValidator' as const,
|
||||
metrics: {
|
||||
blocksProduced: 90,
|
||||
blocksMissed: 10,
|
||||
eraPoints: 500,
|
||||
reputationScore: 90, // (90 * 100) / (90 + 10)
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 12. TOKEN WRAPPER
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockWrapperState = () => ({
|
||||
hezBalance: 1000000000000000n,
|
||||
whezBalance: 500000000000000n,
|
||||
totalLocked: 5000000000000000000n,
|
||||
wrapAmount: 100000000000000n,
|
||||
unwrapAmount: 50000000000000n,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 13. PRESALE
|
||||
// ============================================================================
|
||||
|
||||
export const generateMockPresaleState = (active: boolean = true) => ({
|
||||
active,
|
||||
startBlock: 1,
|
||||
endBlock: 101,
|
||||
currentBlock: active ? 50 : 101,
|
||||
totalRaised: 300000000n, // 300 wUSDT (6 decimals)
|
||||
paused: false,
|
||||
ratio: 100, // 100 wUSDT = 10,000 PEZ
|
||||
});
|
||||
|
||||
export const generateMockPresaleContribution = (amount: number = 100) => ({
|
||||
contributor: `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
amount: amount * 1000000n, // wUSDT has 6 decimals
|
||||
pezToReceive: BigInt(amount * 100) * 1_000_000_000_000n, // PEZ has 12 decimals
|
||||
contributedAt: Date.now(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export const formatBalance = (amount: bigint | string, decimals: number = 12): string => {
|
||||
const value = typeof amount === 'string' ? BigInt(amount) : amount;
|
||||
const divisor = 10n ** BigInt(decimals);
|
||||
const integerPart = value / divisor;
|
||||
const fractionalPart = value % divisor;
|
||||
|
||||
const fractionalStr = fractionalPart.toString().padStart(decimals, '0');
|
||||
return `${integerPart}.${fractionalStr.slice(0, 4)}`; // Show 4 decimals
|
||||
};
|
||||
|
||||
export const parseAmount = (amount: string, decimals: number = 12): bigint => {
|
||||
const [integer, fractional = ''] = amount.split('.');
|
||||
const paddedFractional = fractional.padEnd(decimals, '0').slice(0, decimals);
|
||||
return BigInt(integer + paddedFractional);
|
||||
};
|
||||
|
||||
export const randomAddress = (): string => {
|
||||
return `5${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||
};
|
||||
|
||||
export const randomHash = (): string => {
|
||||
return `0x${Array.from({ length: 64 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join('')}`;
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Test Helper Utilities
|
||||
* Common testing utilities for web and mobile
|
||||
*/
|
||||
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// REACT TESTING LIBRARY HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Custom render function that includes common providers
|
||||
*/
|
||||
export interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
initialState?: any;
|
||||
polkadotConnected?: boolean;
|
||||
walletConnected?: boolean;
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
options?: CustomRenderOptions
|
||||
): RenderResult {
|
||||
const {
|
||||
initialState = {},
|
||||
polkadotConnected = true,
|
||||
walletConnected = true,
|
||||
...renderOptions
|
||||
} = options || {};
|
||||
|
||||
// Wrapper will be platform-specific (web or mobile)
|
||||
// This is a base implementation
|
||||
return render(ui, renderOptions);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ASYNC UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Wait for a condition to be true
|
||||
*/
|
||||
export async function waitForCondition(
|
||||
condition: () => boolean,
|
||||
timeout: number = 5000,
|
||||
interval: number = 100
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (!condition()) {
|
||||
if (Date.now() - startTime > timeout) {
|
||||
throw new Error('Timeout waiting for condition');
|
||||
}
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BLOCKCHAIN MOCK HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mock Polkadot.js API transaction response
|
||||
*/
|
||||
export const mockTransactionResponse = (success: boolean = true) => ({
|
||||
status: {
|
||||
isInBlock: success,
|
||||
isFinalized: success,
|
||||
type: success ? 'InBlock' : 'Invalid',
|
||||
},
|
||||
events: success ? [
|
||||
{
|
||||
event: {
|
||||
section: 'system',
|
||||
method: 'ExtrinsicSuccess',
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
] : [],
|
||||
dispatchError: success ? null : {
|
||||
isModule: true,
|
||||
asModule: {
|
||||
index: { toNumber: () => 0 },
|
||||
error: { toNumber: () => 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock blockchain query response
|
||||
*/
|
||||
export const mockQueryResponse = (data: any) => ({
|
||||
toJSON: () => data,
|
||||
toString: () => JSON.stringify(data),
|
||||
unwrap: () => ({ balance: data }),
|
||||
isEmpty: !data || data.length === 0,
|
||||
toNumber: () => (typeof data === 'number' ? data : 0),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate mock account
|
||||
*/
|
||||
export const mockAccount = (address?: string) => ({
|
||||
address: address || `5${Math.random().toString(36).substring(2, 15)}`,
|
||||
meta: {
|
||||
name: 'Test Account',
|
||||
source: 'polkadot-js',
|
||||
},
|
||||
type: 'sr25519',
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// FORM TESTING HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fill form field by test ID
|
||||
*/
|
||||
export function fillInput(
|
||||
getByTestId: (testId: string) => HTMLElement,
|
||||
testId: string,
|
||||
value: string
|
||||
): void {
|
||||
const input = getByTestId(testId) as HTMLInputElement;
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Click button by test ID
|
||||
*/
|
||||
export function clickButton(
|
||||
getByTestId: (testId: string) => HTMLElement,
|
||||
testId: string
|
||||
): void {
|
||||
const button = getByTestId(testId);
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element has class
|
||||
*/
|
||||
export function hasClass(element: HTMLElement, className: string): boolean {
|
||||
return element.className.includes(className);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate Polkadot address format
|
||||
*/
|
||||
export function isValidPolkadotAddress(address: string): boolean {
|
||||
return /^5[1-9A-HJ-NP-Za-km-z]{47}$/.test(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IPFS CID format
|
||||
*/
|
||||
export function isValidIPFSCID(cid: string): boolean {
|
||||
return /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(cid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate amount format
|
||||
*/
|
||||
export function isValidAmount(amount: string): boolean {
|
||||
return /^\d+(\.\d{1,12})?$/.test(amount);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATA ASSERTION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assert balance matches expected value
|
||||
*/
|
||||
export function assertBalanceEquals(
|
||||
actual: bigint | string,
|
||||
expected: bigint | string,
|
||||
decimals: number = 12
|
||||
): void {
|
||||
const actualBigInt = typeof actual === 'string' ? BigInt(actual) : actual;
|
||||
const expectedBigInt = typeof expected === 'string' ? BigInt(expected) : expected;
|
||||
|
||||
if (actualBigInt !== expectedBigInt) {
|
||||
throw new Error(
|
||||
`Balance mismatch: expected ${expectedBigInt.toString()} but got ${actualBigInt.toString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert percentage within range
|
||||
*/
|
||||
export function assertPercentageInRange(
|
||||
value: number,
|
||||
min: number,
|
||||
max: number
|
||||
): void {
|
||||
if (value < min || value > max) {
|
||||
throw new Error(`Percentage ${value} is not within range [${min}, ${max}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK STATE BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build mock Polkadot context state
|
||||
*/
|
||||
export const buildPolkadotContextState = (overrides: Partial<any> = {}) => ({
|
||||
api: {
|
||||
query: {},
|
||||
tx: {},
|
||||
rpc: {},
|
||||
isReady: Promise.resolve(true),
|
||||
},
|
||||
isApiReady: true,
|
||||
selectedAccount: mockAccount(),
|
||||
accounts: [mockAccount()],
|
||||
connectWallet: jest.fn(),
|
||||
disconnectWallet: jest.fn(),
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* Build mock wallet context state
|
||||
*/
|
||||
export const buildWalletContextState = (overrides: Partial<any> = {}) => ({
|
||||
isConnected: true,
|
||||
account: mockAccount().address,
|
||||
balance: '1000000000000000',
|
||||
balances: {
|
||||
HEZ: '1000000000000000',
|
||||
PEZ: '500000000000000',
|
||||
wHEZ: '300000000000000',
|
||||
USDT: '1000000000', // 6 decimals
|
||||
},
|
||||
signer: {},
|
||||
connectWallet: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
refreshBalances: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ERROR TESTING HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Expect async function to throw
|
||||
*/
|
||||
export async function expectAsyncThrow(
|
||||
fn: () => Promise<any>,
|
||||
expectedError?: string | RegExp
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
throw new Error('Expected function to throw, but it did not');
|
||||
} catch (error: any) {
|
||||
if (expectedError) {
|
||||
const message = error.message || error.toString();
|
||||
if (typeof expectedError === 'string') {
|
||||
if (!message.includes(expectedError)) {
|
||||
throw new Error(
|
||||
`Expected error message to include "${expectedError}", but got "${message}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!expectedError.test(message)) {
|
||||
throw new Error(
|
||||
`Expected error message to match ${expectedError}, but got "${message}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock console.error to suppress expected errors
|
||||
*/
|
||||
export function suppressConsoleError(fn: () => void): void {
|
||||
const originalError = console.error;
|
||||
console.error = jest.fn();
|
||||
fn();
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIMING HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Advance time for Jest fake timers
|
||||
*/
|
||||
export function advanceTimersByTime(ms: number): void {
|
||||
jest.advanceTimersByTime(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending timers
|
||||
*/
|
||||
export function runAllTimers(): void {
|
||||
jest.runAllTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all timers
|
||||
*/
|
||||
export function clearAllTimers(): void {
|
||||
jest.clearAllTimers();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CUSTOM MATCHERS (for Jest)
|
||||
// ============================================================================
|
||||
|
||||
declare global {
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toBeValidPolkadotAddress(): R;
|
||||
toBeValidIPFSCID(): R;
|
||||
toMatchBalance(expected: bigint | string, decimals?: number): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const customMatchers = {
|
||||
toBeValidPolkadotAddress(received: string) {
|
||||
const pass = isValidPolkadotAddress(received);
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected ${received} not to be a valid Polkadot address`
|
||||
: `Expected ${received} to be a valid Polkadot address`,
|
||||
};
|
||||
},
|
||||
|
||||
toBeValidIPFSCID(received: string) {
|
||||
const pass = isValidIPFSCID(received);
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected ${received} not to be a valid IPFS CID`
|
||||
: `Expected ${received} to be a valid IPFS CID`,
|
||||
};
|
||||
},
|
||||
|
||||
toMatchBalance(received: bigint | string, expected: bigint | string, decimals = 12) {
|
||||
const receivedBigInt = typeof received === 'string' ? BigInt(received) : received;
|
||||
const expectedBigInt = typeof expected === 'string' ? BigInt(expected) : expected;
|
||||
const pass = receivedBigInt === expectedBigInt;
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
pass
|
||||
? `Expected balance ${receivedBigInt} not to match ${expectedBigInt}`
|
||||
: `Expected balance ${receivedBigInt} to match ${expectedBigInt}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TEST DATA CLEANUP
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Clean up test data after each test
|
||||
*/
|
||||
export function cleanupTestData(): void {
|
||||
// Clear local storage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
// Clear session storage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.clear();
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
if (typeof document !== 'undefined') {
|
||||
document.cookie.split(';').forEach(cookie => {
|
||||
document.cookie = cookie
|
||||
.replace(/^ +/, '')
|
||||
.replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Cypress E2E Test: Full Citizenship & KYC Flow
|
||||
* Based on pallet-identity-kyc integration tests
|
||||
*
|
||||
* Flow:
|
||||
* 1. Set Identity → 2. Apply for KYC → 3. Admin Approval → 4. Citizen NFT Minted
|
||||
* Alternative: Self-Confirmation flow
|
||||
*/
|
||||
|
||||
describe('Citizenship & KYC Flow (E2E)', () => {
|
||||
const testUser = {
|
||||
name: 'Test Citizen',
|
||||
email: 'testcitizen@pezkuwi.com',
|
||||
wallet: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
|
||||
};
|
||||
|
||||
const testAdmin = {
|
||||
wallet: '5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Visit the citizenship page
|
||||
cy.visit('/citizenship');
|
||||
|
||||
// Mock wallet connection
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotWallet = {
|
||||
address: testUser.wallet,
|
||||
connected: true,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
describe('Happy Path: Full KYC Approval Flow', () => {
|
||||
it('should complete full citizenship flow', () => {
|
||||
// STEP 1: Set Identity
|
||||
cy.log('Step 1: Setting identity');
|
||||
cy.get('[data-testid="identity-name-input"]').type(testUser.name);
|
||||
cy.get('[data-testid="identity-email-input"]').type(testUser.email);
|
||||
cy.get('[data-testid="submit-identity-btn"]').click();
|
||||
|
||||
// Wait for transaction confirmation
|
||||
cy.contains('Identity set successfully', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// STEP 2: Apply for KYC
|
||||
cy.log('Step 2: Applying for KYC');
|
||||
cy.get('[data-testid="kyc-cid-input"]').type('QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG');
|
||||
cy.get('[data-testid="kyc-notes-input"]').type('My citizenship documents');
|
||||
|
||||
// Check deposit amount is displayed
|
||||
cy.contains('Deposit required: 10 HEZ').should('be.visible');
|
||||
|
||||
cy.get('[data-testid="submit-kyc-btn"]').click();
|
||||
|
||||
// Wait for transaction
|
||||
cy.contains('KYC application submitted', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// Verify status changed to Pending
|
||||
cy.get('[data-testid="kyc-status-badge"]').should('contain', 'Pending');
|
||||
|
||||
// STEP 3: Admin Approval (switch to admin account)
|
||||
cy.log('Step 3: Admin approving KYC');
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotWallet.address = testAdmin.wallet;
|
||||
});
|
||||
|
||||
cy.visit('/admin/kyc-applications');
|
||||
cy.get(`[data-testid="approve-kyc-${testUser.wallet}"]`).click();
|
||||
|
||||
// Confirm approval
|
||||
cy.get('[data-testid="confirm-approval-btn"]').click();
|
||||
|
||||
cy.contains('KYC approved successfully', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// STEP 4: Verify Citizen Status
|
||||
cy.log('Step 4: Verifying citizenship status');
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotWallet.address = testUser.wallet;
|
||||
});
|
||||
|
||||
cy.visit('/citizenship');
|
||||
|
||||
// Should show Approved status
|
||||
cy.get('[data-testid="kyc-status-badge"]').should('contain', 'Approved');
|
||||
|
||||
// Should show Citizen NFT
|
||||
cy.contains('Citizen NFT').should('be.visible');
|
||||
|
||||
// Should show Welati role
|
||||
cy.contains('Welati').should('be.visible');
|
||||
|
||||
// Deposit should be refunded (check balance increased)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alternative: Self-Confirmation Flow', () => {
|
||||
it('should allow self-confirmation for Welati NFT holders', () => {
|
||||
// User already has Welati NFT (mock this state)
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotState = {
|
||||
hasWelatiNFT: true,
|
||||
};
|
||||
});
|
||||
|
||||
// STEP 1: Set Identity
|
||||
cy.get('[data-testid="identity-name-input"]').type(testUser.name);
|
||||
cy.get('[data-testid="identity-email-input"]').type(testUser.email);
|
||||
cy.get('[data-testid="submit-identity-btn"]').click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// STEP 2: Apply for KYC
|
||||
cy.get('[data-testid="kyc-cid-input"]').type('QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG');
|
||||
cy.get('[data-testid="submit-kyc-btn"]').click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// STEP 3: Self-Confirm (should be available for Welati holders)
|
||||
cy.get('[data-testid="self-confirm-btn"]').should('be.visible');
|
||||
cy.get('[data-testid="self-confirm-btn"]').click();
|
||||
|
||||
// Confirm action
|
||||
cy.contains('Self-confirm citizenship?').should('be.visible');
|
||||
cy.get('[data-testid="confirm-self-confirm"]').click();
|
||||
|
||||
// Wait for confirmation
|
||||
cy.contains('Citizenship confirmed!', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// Verify status
|
||||
cy.get('[data-testid="kyc-status-badge"]').should('contain', 'Approved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Cases', () => {
|
||||
it('should prevent KYC application without identity', () => {
|
||||
// Try to submit KYC without setting identity first
|
||||
cy.get('[data-testid="kyc-cid-input"]').should('be.disabled');
|
||||
|
||||
// Should show message
|
||||
cy.contains('Please set your identity first').should('be.visible');
|
||||
});
|
||||
|
||||
it('should prevent duplicate KYC application', () => {
|
||||
// Mock existing pending application
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotState = {
|
||||
kycStatus: 'Pending',
|
||||
};
|
||||
});
|
||||
|
||||
cy.reload();
|
||||
|
||||
// KYC form should be disabled
|
||||
cy.get('[data-testid="submit-kyc-btn"]').should('be.disabled');
|
||||
|
||||
// Should show current status
|
||||
cy.contains('Application already submitted').should('be.visible');
|
||||
cy.get('[data-testid="kyc-status-badge"]').should('contain', 'Pending');
|
||||
});
|
||||
|
||||
it('should show insufficient balance error', () => {
|
||||
// Mock low balance
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotState = {
|
||||
balance: 5_000_000_000_000n, // 5 HEZ (less than 10 required)
|
||||
};
|
||||
});
|
||||
|
||||
// Set identity first
|
||||
cy.get('[data-testid="identity-name-input"]').type(testUser.name);
|
||||
cy.get('[data-testid="identity-email-input"]').type(testUser.email);
|
||||
cy.get('[data-testid="submit-identity-btn"]').click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// Try to submit KYC
|
||||
cy.get('[data-testid="kyc-cid-input"]').type('QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG');
|
||||
cy.get('[data-testid="submit-kyc-btn"]').click();
|
||||
|
||||
// Should show error
|
||||
cy.contains('Insufficient balance').should('be.visible');
|
||||
cy.contains('You need at least 10 HEZ').should('be.visible');
|
||||
});
|
||||
|
||||
it('should validate identity name length (max 50 chars)', () => {
|
||||
const longName = 'a'.repeat(51);
|
||||
|
||||
cy.get('[data-testid="identity-name-input"]').type(longName);
|
||||
cy.get('[data-testid="submit-identity-btn"]').click();
|
||||
|
||||
// Should show validation error
|
||||
cy.contains(/name must be 50 characters or less/i).should('be.visible');
|
||||
});
|
||||
|
||||
it('should validate IPFS CID format', () => {
|
||||
// Set identity first
|
||||
cy.get('[data-testid="identity-name-input"]').type(testUser.name);
|
||||
cy.get('[data-testid="identity-email-input"]').type(testUser.email);
|
||||
cy.get('[data-testid="submit-identity-btn"]').click();
|
||||
|
||||
cy.wait(2000);
|
||||
|
||||
// Enter invalid CID
|
||||
const invalidCIDs = ['invalid', 'Qm123', 'notacid'];
|
||||
|
||||
invalidCIDs.forEach((cid) => {
|
||||
cy.get('[data-testid="kyc-cid-input"]').clear().type(cid);
|
||||
cy.get('[data-testid="submit-kyc-btn"]').click();
|
||||
|
||||
cy.contains(/invalid IPFS CID/i).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citizenship Renunciation', () => {
|
||||
it('should allow approved citizens to renounce', () => {
|
||||
// Mock approved citizen state
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotState = {
|
||||
kycStatus: 'Approved',
|
||||
citizenNFTId: 123,
|
||||
};
|
||||
});
|
||||
|
||||
cy.visit('/citizenship');
|
||||
|
||||
// Should show renounce button
|
||||
cy.get('[data-testid="renounce-btn"]').should('be.visible');
|
||||
cy.get('[data-testid="renounce-btn"]').click();
|
||||
|
||||
// Confirm renunciation (should show strong warning)
|
||||
cy.contains(/are you sure/i).should('be.visible');
|
||||
cy.contains(/this action cannot be undone/i).should('be.visible');
|
||||
cy.get('[data-testid="confirm-renounce"]').click();
|
||||
|
||||
// Wait for transaction
|
||||
cy.contains('Citizenship renounced', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// Status should reset to NotStarted
|
||||
cy.get('[data-testid="kyc-status-badge"]').should('contain', 'Not Started');
|
||||
});
|
||||
|
||||
it('should allow reapplication after renunciation', () => {
|
||||
// After renouncing (status: NotStarted)
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotState = {
|
||||
kycStatus: 'NotStarted',
|
||||
previouslyRenounced: true,
|
||||
};
|
||||
});
|
||||
|
||||
cy.visit('/citizenship');
|
||||
|
||||
// Identity and KYC forms should be available again
|
||||
cy.get('[data-testid="identity-name-input"]').should('not.be.disabled');
|
||||
cy.contains(/you can reapply/i).should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin KYC Management', () => {
|
||||
beforeEach(() => {
|
||||
// Switch to admin account
|
||||
cy.window().then((win) => {
|
||||
(win as any).mockPolkadotWallet.address = testAdmin.wallet;
|
||||
});
|
||||
|
||||
cy.visit('/admin/kyc-applications');
|
||||
});
|
||||
|
||||
it('should display pending KYC applications', () => {
|
||||
cy.get('[data-testid="kyc-application-row"]').should('have.length.greaterThan', 0);
|
||||
|
||||
// Each row should show:
|
||||
cy.contains(testUser.name).should('be.visible');
|
||||
cy.contains('Pending').should('be.visible');
|
||||
});
|
||||
|
||||
it('should approve KYC application', () => {
|
||||
cy.get(`[data-testid="approve-kyc-${testUser.wallet}"]`).first().click();
|
||||
cy.get('[data-testid="confirm-approval-btn"]').click();
|
||||
|
||||
cy.contains('KYC approved', { timeout: 10000 }).should('be.visible');
|
||||
|
||||
// Application should disappear from pending list
|
||||
cy.get(`[data-testid="approve-kyc-${testUser.wallet}"]`).should('not.exist');
|
||||
});
|
||||
|
||||
it('should reject KYC application', () => {
|
||||
cy.get(`[data-testid="reject-kyc-${testUser.wallet}"]`).first().click();
|
||||
|
||||
// Enter rejection reason
|
||||
cy.get('[data-testid="rejection-reason"]').type('Incomplete documents');
|
||||
cy.get('[data-testid="confirm-rejection-btn"]').click();
|
||||
|
||||
cy.contains('KYC rejected', { timeout: 10000 }).should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* KYC Application Component Tests
|
||||
* Based on pallet-identity-kyc tests
|
||||
*
|
||||
* Tests cover:
|
||||
* - set_identity_works
|
||||
* - apply_for_kyc_works
|
||||
* - apply_for_kyc_fails_if_no_identity
|
||||
* - apply_for_kyc_fails_if_already_pending
|
||||
* - confirm_citizenship_works
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
generateMockIdentity,
|
||||
generateMockKYCApplication,
|
||||
} from '../../../utils/mockDataGenerators';
|
||||
import {
|
||||
buildPolkadotContextState,
|
||||
mockTransactionResponse,
|
||||
expectAsyncThrow,
|
||||
} from '../../../utils/testHelpers';
|
||||
|
||||
// Mock the KYC Application component (adjust path as needed)
|
||||
// import { KYCApplicationForm } from '@/components/citizenship/KYCApplication';
|
||||
|
||||
describe('KYC Application Component', () => {
|
||||
let mockApi: any;
|
||||
let mockSigner: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mock Polkadot API
|
||||
mockApi = {
|
||||
query: {
|
||||
identityKyc: {
|
||||
identities: jest.fn(),
|
||||
applications: jest.fn(),
|
||||
},
|
||||
},
|
||||
tx: {
|
||||
identityKyc: {
|
||||
setIdentity: jest.fn(() => ({
|
||||
signAndSend: jest.fn((account, callback) => {
|
||||
callback(mockTransactionResponse(true));
|
||||
return Promise.resolve('0x123');
|
||||
}),
|
||||
})),
|
||||
applyForKyc: jest.fn(() => ({
|
||||
signAndSend: jest.fn((account, callback) => {
|
||||
callback(mockTransactionResponse(true));
|
||||
return Promise.resolve('0x123');
|
||||
}),
|
||||
})),
|
||||
confirmCitizenship: jest.fn(() => ({
|
||||
signAndSend: jest.fn((account, callback) => {
|
||||
callback(mockTransactionResponse(true));
|
||||
return Promise.resolve('0x123');
|
||||
}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockSigner = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Identity Setup', () => {
|
||||
test('should validate name field (max 50 chars)', () => {
|
||||
// Test: set_identity_with_max_length_strings
|
||||
const longName = 'a'.repeat(51);
|
||||
const identity = generateMockIdentity(longName);
|
||||
|
||||
expect(identity.name.length).toBeGreaterThan(50);
|
||||
|
||||
// Component should reject this
|
||||
// In real test:
|
||||
// render(<KYCApplicationForm />);
|
||||
// const nameInput = screen.getByTestId('identity-name-input');
|
||||
// fireEvent.change(nameInput, { target: { value: longName } });
|
||||
// expect(screen.getByText(/name must be 50 characters or less/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should validate email field', () => {
|
||||
const invalidEmails = ['invalid', 'test@', '@test.com', 'test @test.com'];
|
||||
|
||||
invalidEmails.forEach(email => {
|
||||
const identity = generateMockIdentity('Test User', email);
|
||||
// Component should show validation error
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully set identity with valid data', async () => {
|
||||
const mockIdentity = generateMockIdentity();
|
||||
|
||||
mockApi.query.identityKyc.identities.mockResolvedValue({
|
||||
unwrap: () => null, // No existing identity
|
||||
});
|
||||
|
||||
// Simulate form submission
|
||||
const tx = mockApi.tx.identityKyc.setIdentity(
|
||||
mockIdentity.name,
|
||||
mockIdentity.email
|
||||
);
|
||||
|
||||
await expect(tx.signAndSend('address', jest.fn())).resolves.toBe('0x123');
|
||||
|
||||
expect(mockApi.tx.identityKyc.setIdentity).toHaveBeenCalledWith(
|
||||
mockIdentity.name,
|
||||
mockIdentity.email
|
||||
);
|
||||
});
|
||||
|
||||
test('should fail when identity already exists', async () => {
|
||||
// Test: set_identity_fails_if_already_exists
|
||||
const mockIdentity = generateMockIdentity();
|
||||
|
||||
mockApi.query.identityKyc.identities.mockResolvedValue({
|
||||
unwrap: () => mockIdentity, // Existing identity
|
||||
});
|
||||
|
||||
// Component should show "Identity already set" message
|
||||
// and disable the form
|
||||
});
|
||||
});
|
||||
|
||||
describe('KYC Application', () => {
|
||||
test('should show deposit amount before submission', () => {
|
||||
const mockKYC = generateMockKYCApplication();
|
||||
|
||||
// Component should display: "Deposit required: 10 HEZ"
|
||||
expect(mockKYC.depositAmount).toBe(10_000_000_000_000n);
|
||||
});
|
||||
|
||||
test('should validate IPFS CID format', () => {
|
||||
const invalidCIDs = [
|
||||
'invalid',
|
||||
'Qm123', // too short
|
||||
'Rm' + 'a'.repeat(44), // wrong prefix
|
||||
];
|
||||
|
||||
const validCID = 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG';
|
||||
|
||||
invalidCIDs.forEach(cid => {
|
||||
// Component should reject invalid CIDs
|
||||
});
|
||||
|
||||
// Component should accept valid CID
|
||||
});
|
||||
|
||||
test('should fail if identity not set', async () => {
|
||||
// Test: apply_for_kyc_fails_if_no_identity
|
||||
mockApi.query.identityKyc.identities.mockResolvedValue({
|
||||
unwrap: () => null,
|
||||
});
|
||||
|
||||
// Component should show "Please set identity first" error
|
||||
// and disable KYC form
|
||||
});
|
||||
|
||||
test('should fail if application already pending', async () => {
|
||||
// Test: apply_for_kyc_fails_if_already_pending
|
||||
const pendingKYC = generateMockKYCApplication('Pending');
|
||||
|
||||
mockApi.query.identityKyc.applications.mockResolvedValue({
|
||||
unwrap: () => pendingKYC,
|
||||
});
|
||||
|
||||
// Component should show "Application already submitted" message
|
||||
// and show current status
|
||||
});
|
||||
|
||||
test('should successfully submit KYC application', async () => {
|
||||
const mockKYC = generateMockKYCApplication();
|
||||
|
||||
mockApi.query.identityKyc.identities.mockResolvedValue({
|
||||
unwrap: () => generateMockIdentity(),
|
||||
});
|
||||
|
||||
mockApi.query.identityKyc.applications.mockResolvedValue({
|
||||
unwrap: () => null, // No existing application
|
||||
});
|
||||
|
||||
const tx = mockApi.tx.identityKyc.applyForKyc(
|
||||
mockKYC.cids,
|
||||
mockKYC.notes
|
||||
);
|
||||
|
||||
await expect(tx.signAndSend('address', jest.fn())).resolves.toBe('0x123');
|
||||
|
||||
expect(mockApi.tx.identityKyc.applyForKyc).toHaveBeenCalledWith(
|
||||
mockKYC.cids,
|
||||
mockKYC.notes
|
||||
);
|
||||
});
|
||||
|
||||
test('should check insufficient balance before submission', () => {
|
||||
const depositRequired = 10_000_000_000_000n;
|
||||
const userBalance = 5_000_000_000_000n; // Less than required
|
||||
|
||||
// Component should show "Insufficient balance" error
|
||||
// and disable submit button
|
||||
});
|
||||
});
|
||||
|
||||
describe('KYC Status Display', () => {
|
||||
test('should show "Pending" status with deposit amount', () => {
|
||||
const pendingKYC = generateMockKYCApplication('Pending');
|
||||
|
||||
// Component should display:
|
||||
// - Status badge: "Pending"
|
||||
// - Deposit amount: "10 HEZ"
|
||||
// - Message: "Your application is under review"
|
||||
});
|
||||
|
||||
test('should show "Approved" status with success message', () => {
|
||||
const approvedKYC = generateMockKYCApplication('Approved');
|
||||
|
||||
// Component should display:
|
||||
// - Status badge: "Approved" (green)
|
||||
// - Message: "Congratulations! Your KYC has been approved"
|
||||
// - Citizen NFT info
|
||||
});
|
||||
|
||||
test('should show "Rejected" status with reason', () => {
|
||||
const rejectedKYC = generateMockKYCApplication('Rejected');
|
||||
|
||||
// Component should display:
|
||||
// - Status badge: "Rejected" (red)
|
||||
// - Message: "Your application was rejected"
|
||||
// - Button: "Reapply"
|
||||
});
|
||||
});
|
||||
|
||||
describe('Self-Confirmation', () => {
|
||||
test('should enable self-confirmation button for pending applications', () => {
|
||||
// Test: confirm_citizenship_works
|
||||
const pendingKYC = generateMockKYCApplication('Pending');
|
||||
|
||||
// Component should show "Self-Confirm Citizenship" button
|
||||
// (for Welati NFT holders)
|
||||
});
|
||||
|
||||
test('should successfully self-confirm citizenship', async () => {
|
||||
const tx = mockApi.tx.identityKyc.confirmCitizenship();
|
||||
|
||||
await expect(tx.signAndSend('address', jest.fn())).resolves.toBe('0x123');
|
||||
|
||||
expect(mockApi.tx.identityKyc.confirmCitizenship).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should fail self-confirmation when not pending', () => {
|
||||
// Test: confirm_citizenship_fails_when_not_pending
|
||||
const approvedKYC = generateMockKYCApplication('Approved');
|
||||
|
||||
// Self-confirm button should be hidden or disabled
|
||||
});
|
||||
});
|
||||
|
||||
describe('Citizenship Renunciation', () => {
|
||||
test('should show renounce button for approved citizens', () => {
|
||||
// Test: renounce_citizenship_works
|
||||
const approvedKYC = generateMockKYCApplication('Approved');
|
||||
|
||||
// Component should show "Renounce Citizenship" button
|
||||
// with confirmation dialog
|
||||
});
|
||||
|
||||
test('should allow reapplication after renunciation', () => {
|
||||
// Test: renounce_citizenship_allows_reapplication
|
||||
const notStartedKYC = generateMockKYCApplication('NotStarted');
|
||||
|
||||
// After renouncing, status should be NotStarted
|
||||
// and user can apply again (free world principle)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Actions', () => {
|
||||
test('should show approve/reject buttons for root users', () => {
|
||||
// Test: approve_kyc_works, reject_kyc_works
|
||||
const isRoot = true; // Mock root check
|
||||
|
||||
if (isRoot) {
|
||||
// Component should show admin panel with:
|
||||
// - "Approve KYC" button
|
||||
// - "Reject KYC" button
|
||||
// - Application details
|
||||
}
|
||||
});
|
||||
|
||||
test('should refund deposit on approval', () => {
|
||||
// After approval, deposit should be refunded to applicant
|
||||
const depositAmount = 10_000_000_000_000n;
|
||||
|
||||
// Component should show: "Deposit refunded: 10 HEZ"
|
||||
});
|
||||
|
||||
test('should refund deposit on rejection', () => {
|
||||
// After rejection, deposit should be refunded to applicant
|
||||
const depositAmount = 10_000_000_000_000n;
|
||||
|
||||
// Component should show: "Deposit refunded: 10 HEZ"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TEST DATA FIXTURES
|
||||
*/
|
||||
export const kycTestFixtures = {
|
||||
validIdentity: generateMockIdentity(),
|
||||
invalidIdentity: {
|
||||
name: 'a'.repeat(51), // Too long
|
||||
email: 'invalid-email',
|
||||
},
|
||||
pendingApplication: generateMockKYCApplication('Pending'),
|
||||
approvedApplication: generateMockKYCApplication('Approved'),
|
||||
rejectedApplication: generateMockKYCApplication('Rejected'),
|
||||
validCIDs: [
|
||||
'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG',
|
||||
'QmT5NvUtoM5nWFfrQdVrFtvGfKFmG7AHE8P34isapyhCxX',
|
||||
],
|
||||
depositAmount: 10_000_000_000_000n,
|
||||
};
|
||||
Generated
+1
-1
@@ -88,7 +88,7 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"globals": "^15.15.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.4.47",
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"globals": "^15.15.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.4.47",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { ASSET_IDS, ASSET_CONFIGS } from '../../../shared/lib/wallet';
|
||||
|
||||
interface InitializeUsdtModalProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
@@ -27,15 +27,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
const [kycStatus, setKycStatus] = useState<string>('NotStarted');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
if (selectedAccount && api && isApiReady) {
|
||||
fetchScoresAndTikis();
|
||||
|
||||
}
|
||||
}, [user, selectedAccount, api, isApiReady]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
const fetchProfile = useCallback(async () => {
|
||||
if (!user) {
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -59,9 +51,9 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const fetchScoresAndTikis = async () => {
|
||||
const fetchScoresAndTikis = useCallback(async () => {
|
||||
if (!selectedAccount || !api) return;
|
||||
|
||||
setLoading(true);
|
||||
@@ -76,7 +68,15 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [selectedAccount, api]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
if (selectedAccount && api && isApiReady) {
|
||||
fetchScoresAndTikis();
|
||||
|
||||
}
|
||||
}, [user, selectedAccount, api, isApiReady, fetchProfile, fetchScoresAndTikis]);
|
||||
|
||||
const citizenNumber = nftDetails.citizenNFT
|
||||
? generateCitizenNumber(nftDetails.citizenNFT.owner, nftDetails.citizenNFT.collectionId, nftDetails.citizenNFT.itemId)
|
||||
|
||||
@@ -124,6 +124,7 @@ export const PolkadotProvider: React.FC<PolkadotProviderProps> = ({
|
||||
api.disconnect();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint]);
|
||||
|
||||
// Auto-restore wallet on page load
|
||||
|
||||
@@ -239,7 +239,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
if (polkadot.selectedAccount && polkadot.isApiReady) {
|
||||
updateBalance(polkadot.selectedAccount.address);
|
||||
}
|
||||
}, [polkadot.selectedAccount, polkadot.isApiReady]);
|
||||
}, [polkadot.selectedAccount, polkadot.isApiReady, updateBalance]);
|
||||
|
||||
// Sync error state with PolkadotContext
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,6 +17,13 @@ interface WebSocketContextType {
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||
|
||||
const ENDPOINTS = [
|
||||
'ws://localhost:8082', // Local Vite dev server
|
||||
'ws://127.0.0.1:9944', // Local development node (primary)
|
||||
'ws://localhost:9944', // Local development node (alternative)
|
||||
'wss://ws.pezkuwichain.io', // Production WebSocket (fallback)
|
||||
];
|
||||
|
||||
export const useWebSocket = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
@@ -31,18 +38,11 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||
const reconnectTimeout = useRef<NodeJS.Timeout>();
|
||||
const eventListeners = useRef<Map<string, Set<(data: Record<string, unknown>) => void>>>(new Map());
|
||||
const { toast } = useToast();
|
||||
|
||||
|
||||
// Connection state management
|
||||
const currentEndpoint = useRef<string>('');
|
||||
const hasShownFinalError = useRef(false);
|
||||
const connectionAttempts = useRef(0);
|
||||
|
||||
const ENDPOINTS = [
|
||||
'ws://localhost:8082', // Local Vite dev server
|
||||
'ws://127.0.0.1:9944', // Local development node (primary)
|
||||
'ws://localhost:9944', // Local development node (alternative)
|
||||
'wss://ws.pezkuwichain.io', // Production WebSocket (fallback)
|
||||
];
|
||||
|
||||
const connect = useCallback((endpointIndex: number = 0) => {
|
||||
// If we've tried all endpoints, show error once and stop
|
||||
|
||||
@@ -95,6 +95,7 @@ export function useForum() {
|
||||
discussionsSubscription.unsubscribe();
|
||||
announcementsSubscription.unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchForumData = async () => {
|
||||
|
||||
+15
-16
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -40,17 +40,7 @@ export default function Dashboard() {
|
||||
totalNFTs: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
if (selectedAccount && api && isApiReady) {
|
||||
fetchScoresAndTikis();
|
||||
|
||||
|
||||
}
|
||||
}, [user, selectedAccount, api, isApiReady]);
|
||||
|
||||
|
||||
const fetchProfile = async () => {
|
||||
const fetchProfile = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
@@ -107,10 +97,10 @@ export default function Dashboard() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const fetchScoresAndTikis = useCallback(async () => {
|
||||
|
||||
const fetchScoresAndTikis = async () => {
|
||||
|
||||
if (!selectedAccount || !api) return;
|
||||
|
||||
setLoadingScores(true);
|
||||
@@ -135,7 +125,16 @@ export default function Dashboard() {
|
||||
} finally {
|
||||
setLoadingScores(false);
|
||||
}
|
||||
};
|
||||
}, [selectedAccount, api]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
if (selectedAccount && api && isApiReady) {
|
||||
fetchScoresAndTikis();
|
||||
|
||||
|
||||
}
|
||||
}, [user, selectedAccount, api, isApiReady, fetchProfile, fetchScoresAndTikis]);
|
||||
|
||||
const sendVerificationEmail = async () => {
|
||||
if (!user?.email) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
@@ -34,16 +34,9 @@ export default function ProfileSettings() {
|
||||
two_factor_enabled: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadProfile();
|
||||
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user?.id)
|
||||
@@ -73,7 +66,14 @@ export default function ProfileSettings() {
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error('Error loading profile:', error);
|
||||
}
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadProfile();
|
||||
|
||||
}
|
||||
}, [user, loadProfile]);
|
||||
|
||||
const updateProfile = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -129,8 +129,9 @@ export default function CitizensIssues() {
|
||||
useEffect(() => {
|
||||
if (isApiReady && selectedAccount) {
|
||||
fetchAllData();
|
||||
|
||||
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isApiReady, selectedAccount, activeTab]);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user