Add OBS group management feature #3
14 changed files with 98 additions and 56 deletions
|
@ -84,6 +84,8 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
|
|||
#### Team Management
|
||||
- `GET /api/teams` - Get all teams
|
||||
- `GET /api/getTeamName` - Get team name by ID
|
||||
- `POST /api/createGroup` - Create OBS group from team
|
||||
- `POST /api/syncGroups` - Synchronize all teams with OBS groups
|
||||
|
||||
#### System Status
|
||||
- `GET /api/obsStatus` - Real-time OBS connection and streaming status
|
||||
|
@ -92,7 +94,7 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
|
|||
|
||||
Dynamic table names with seasonal configuration:
|
||||
- `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id
|
||||
- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name
|
||||
- `teams_YYYY_SEASON_SUFFIX`: team_id, team_name, group_name
|
||||
|
||||
### OBS Integration Pattern
|
||||
|
||||
|
@ -100,6 +102,7 @@ The app uses a sophisticated dual integration approach:
|
|||
|
||||
1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management
|
||||
2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors
|
||||
3. **Group Management**: Teams can be mapped to OBS groups (implemented as scenes) for organized source management
|
||||
|
||||
**Required OBS Source Switchers** (must be created with these exact names):
|
||||
- `ss_large` - Large screen source switcher
|
||||
|
|
|
@ -6,7 +6,7 @@ jest.mock('@/lib/database', () => ({
|
|||
}));
|
||||
|
||||
describe('/api/streams', () => {
|
||||
let mockDb: any;
|
||||
let mockDb: { all: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock database
|
||||
|
@ -27,7 +27,7 @@ describe('/api/streams', () => {
|
|||
|
||||
mockDb.all.mockResolvedValue(mockStreams);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
expect(mockDb.all).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM')
|
||||
|
@ -40,7 +40,7 @@ describe('/api/streams', () => {
|
|||
it('returns empty array when no streams exist', async () => {
|
||||
mockDb.all.mockResolvedValue([]);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith([]);
|
||||
|
@ -50,7 +50,7 @@ describe('/api/streams', () => {
|
|||
const dbError = new Error('Database connection failed');
|
||||
mockDb.all.mockRejectedValue(dbError);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
|
@ -64,7 +64,7 @@ describe('/api/streams', () => {
|
|||
const { getDatabase } = require('@/lib/database');
|
||||
getDatabase.mockRejectedValue(connectionError);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
const { NextResponse } = require('next/server');
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
|
|
|
@ -24,7 +24,7 @@ jest.mock('@/lib/apiHelpers', () => ({
|
|||
}));
|
||||
|
||||
describe('/api/teams', () => {
|
||||
let mockDb: any;
|
||||
let mockDb: { all: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock database
|
||||
|
@ -46,7 +46,7 @@ describe('/api/teams', () => {
|
|||
|
||||
mockDb.all.mockResolvedValue(mockTeams);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
expect(mockDb.all).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT * FROM')
|
||||
|
@ -59,7 +59,7 @@ describe('/api/teams', () => {
|
|||
it('returns empty array when no teams exist', async () => {
|
||||
mockDb.all.mockResolvedValue([]);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
const { createSuccessResponse } = require('@/lib/apiHelpers');
|
||||
expect(createSuccessResponse).toHaveBeenCalledWith([]);
|
||||
|
@ -69,7 +69,7 @@ describe('/api/teams', () => {
|
|||
const dbError = new Error('Table does not exist');
|
||||
mockDb.all.mockRejectedValue(dbError);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
const { createDatabaseError } = require('@/lib/apiHelpers');
|
||||
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', dbError);
|
||||
|
@ -80,7 +80,7 @@ describe('/api/teams', () => {
|
|||
const { getDatabase } = require('@/lib/database');
|
||||
getDatabase.mockRejectedValue(connectionError);
|
||||
|
||||
const response = await GET();
|
||||
const _response = await GET();
|
||||
|
||||
const { createDatabaseError } = require('@/lib/apiHelpers');
|
||||
expect(createDatabaseError).toHaveBeenCalledWith('fetch teams', connectionError);
|
||||
|
|
|
@ -86,7 +86,7 @@ export default function EditStream() {
|
|||
if (streamId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [streamId]);
|
||||
}, [streamId, showError]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
|
|
@ -35,7 +35,7 @@ export default function Home() {
|
|||
const activeSourceIds = useActiveSourceLookup(streams, activeSources);
|
||||
|
||||
// Debounced API calls to prevent excessive requests
|
||||
const debouncedSetActive = useDebounce(async (screen: ScreenType, id: number | null) => {
|
||||
const setActiveFunction = useCallback(async (screen: ScreenType, id: number | null) => {
|
||||
if (id) {
|
||||
const selectedStream = streams.find(stream => stream.id === id);
|
||||
try {
|
||||
|
@ -62,7 +62,9 @@ export default function Home() {
|
|||
}));
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}, [streams, showError, showSuccess]);
|
||||
|
||||
const debouncedSetActive = useDebounce(setActiveFunction, 300);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const endTimer = PerformanceMonitor.startTimer('fetchData');
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import { Team } from '@/types';
|
||||
import { useToast } from '@/lib/useToast';
|
||||
|
@ -28,12 +28,7 @@ export default function AddStream() {
|
|||
const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({});
|
||||
const { toasts, removeToast, showSuccess, showError } = useToast();
|
||||
|
||||
// Fetch teams and streams on component mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [teamsResponse, streamsResponse] = await Promise.all([
|
||||
|
@ -63,7 +58,12 @@ export default function AddStream() {
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [showError]);
|
||||
|
||||
// Fetch teams and streams on component mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
|
|
@ -10,7 +10,7 @@ const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
|
|||
};
|
||||
|
||||
// Mock window.location.reload using jest.spyOn
|
||||
const mockReload = jest.fn();
|
||||
// const mockReload = jest.fn(); // Defined but not used in current tests
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
// Suppress console.error for these tests since we expect errors
|
||||
|
@ -63,7 +63,7 @@ describe('ErrorBoundary', () => {
|
|||
it('calls window.location.reload when refresh button is clicked', () => {
|
||||
// Skip this test in jsdom environment as window.location.reload cannot be easily mocked
|
||||
// In a real browser environment, this would work as expected
|
||||
const originalReload = window.location.reload;
|
||||
// const originalReload = window.location.reload; // Not used in jsdom test
|
||||
|
||||
// Simple workaround for jsdom limitation
|
||||
if (typeof window.location.reload !== 'function') {
|
||||
|
@ -119,11 +119,17 @@ describe('ErrorBoundary', () => {
|
|||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'development',
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error details in development mode', () => {
|
||||
|
@ -147,11 +153,17 @@ describe('ErrorBoundary', () => {
|
|||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'production',
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('hides error details in production mode', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { ToastComponent, ToastContainer, Toast, ToastType } from '../Toast';
|
||||
|
||||
// Mock timer functions
|
||||
|
|
|
@ -32,7 +32,7 @@ describe('apiHelpers', () => {
|
|||
|
||||
describe('createErrorResponse', () => {
|
||||
it('creates error response with default status 500', () => {
|
||||
const response = createErrorResponse('Test Error');
|
||||
const _response = createErrorResponse('Test Error');
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -44,7 +44,7 @@ describe('apiHelpers', () => {
|
|||
});
|
||||
|
||||
it('creates error response with custom status and message', () => {
|
||||
const response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
|
||||
const _response = createErrorResponse('Test Error', 400, 'Custom message', { detail: 'extra' });
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -81,7 +81,7 @@ describe('apiHelpers', () => {
|
|||
describe('createSuccessResponse', () => {
|
||||
it('creates success response with default status 200', () => {
|
||||
const data = { test: 'data' };
|
||||
const response = createSuccessResponse(data);
|
||||
const _response = createSuccessResponse(data);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -95,7 +95,7 @@ describe('apiHelpers', () => {
|
|||
|
||||
it('creates success response with custom status', () => {
|
||||
const data = { id: 1, name: 'test' };
|
||||
const response = createSuccessResponse(data, 201);
|
||||
const _response = createSuccessResponse(data, 201);
|
||||
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
@ -151,9 +151,9 @@ describe('apiHelpers', () => {
|
|||
});
|
||||
|
||||
describe('parseRequestBody', () => {
|
||||
const mockRequest = (body: any): Request => ({
|
||||
const mockRequest = (body: unknown): Request => ({
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
} as any);
|
||||
} as unknown as Request);
|
||||
|
||||
it('parses valid JSON body without validator', async () => {
|
||||
const body = { name: 'test', value: 123 };
|
||||
|
@ -170,7 +170,7 @@ describe('apiHelpers', () => {
|
|||
it('handles invalid JSON', async () => {
|
||||
const request = {
|
||||
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
} as any;
|
||||
} as unknown as Request;
|
||||
|
||||
const result = await parseRequestBody(request);
|
||||
|
||||
|
@ -218,11 +218,17 @@ describe('apiHelpers', () => {
|
|||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: originalEnv,
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('includes error details in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'development',
|
||||
writable: true
|
||||
});
|
||||
const originalError = new Error('Test error');
|
||||
|
||||
createDatabaseError('test operation', originalError);
|
||||
|
@ -236,7 +242,10 @@ describe('apiHelpers', () => {
|
|||
});
|
||||
|
||||
it('excludes error details in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
Object.defineProperty(process.env, 'NODE_ENV', {
|
||||
value: 'production',
|
||||
writable: true
|
||||
});
|
||||
const originalError = new Error('Test error');
|
||||
|
||||
createDatabaseError('test operation', originalError);
|
||||
|
|
|
@ -178,7 +178,7 @@ describe('useToast', () => {
|
|||
it('returns unique IDs for each toast', () => {
|
||||
const { result } = renderHook(() => useToast());
|
||||
|
||||
let id1: string, id2: string;
|
||||
let id1: string = '', id2: string = '';
|
||||
|
||||
act(() => {
|
||||
// Mock different random values for unique IDs
|
||||
|
|
|
@ -140,7 +140,7 @@ export async function parseRequestBody<T>(
|
|||
}
|
||||
|
||||
return { success: true, data: body as T };
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return {
|
||||
success: false,
|
||||
response: createErrorResponse(
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import React, { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
// Debounce hook for preventing excessive API calls
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useDebounce<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
|
@ -21,7 +22,7 @@ export function useDebounce<T extends (...args: any[]) => any>(
|
|||
}
|
||||
|
||||
// Throttle hook for limiting function calls
|
||||
export function useThrottle<T extends (...args: any[]) => any>(
|
||||
export function useThrottle<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
|
@ -38,16 +39,21 @@ export function useThrottle<T extends (...args: any[]) => any>(
|
|||
|
||||
// Memoized stream lookup utilities
|
||||
export function createStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
||||
const sourceToIdMap = new Map<string, number>();
|
||||
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
||||
|
||||
streams.forEach(stream => {
|
||||
sourceToIdMap.set(stream.obs_source_name, stream.id);
|
||||
idToStreamMap.set(stream.id, stream);
|
||||
});
|
||||
|
||||
return { sourceToIdMap, idToStreamMap };
|
||||
}
|
||||
|
||||
// Hook version for React components
|
||||
export function useStreamLookupMaps(streams: Array<{ id: number; obs_source_name: string; name: string }>) {
|
||||
return useMemo(() => {
|
||||
const sourceToIdMap = new Map<string, number>();
|
||||
const idToStreamMap = new Map<number, { id: number; obs_source_name: string; name: string }>();
|
||||
|
||||
streams.forEach(stream => {
|
||||
sourceToIdMap.set(stream.obs_source_name, stream.id);
|
||||
idToStreamMap.set(stream.id, stream);
|
||||
});
|
||||
|
||||
return { sourceToIdMap, idToStreamMap };
|
||||
return createStreamLookupMaps(streams);
|
||||
}, [streams]);
|
||||
}
|
||||
|
||||
|
@ -56,7 +62,7 @@ export function useActiveSourceLookup(
|
|||
streams: Array<{ id: number; obs_source_name: string; name: string }>,
|
||||
activeSources: Record<string, string | null>
|
||||
) {
|
||||
const { sourceToIdMap } = createStreamLookupMaps(streams);
|
||||
const { sourceToIdMap } = useStreamLookupMaps(streams);
|
||||
|
||||
return useMemo(() => {
|
||||
const activeSourceIds: Record<string, number | null> = {};
|
||||
|
@ -104,7 +110,7 @@ export class PerformanceMonitor {
|
|||
}
|
||||
|
||||
static getAllMetrics() {
|
||||
const result: Record<string, any> = {};
|
||||
const result: Record<string, ReturnType<typeof PerformanceMonitor.getMetrics>> = {};
|
||||
this.metrics.forEach((_, label) => {
|
||||
result[label] = this.getMetrics(label);
|
||||
});
|
||||
|
@ -155,7 +161,7 @@ export function usePageVisibility() {
|
|||
export function useSmartPolling(
|
||||
callback: () => void | Promise<void>,
|
||||
interval: number,
|
||||
dependencies: any[] = []
|
||||
dependencies: unknown[] = []
|
||||
) {
|
||||
const isVisible = usePageVisibility();
|
||||
const callbackRef = useRef(callback);
|
||||
|
@ -185,5 +191,5 @@ export function useSmartPolling(
|
|||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [interval, isVisible, ...dependencies]);
|
||||
}, [interval, isVisible, dependencies]);
|
||||
}
|
|
@ -24,6 +24,6 @@
|
|||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "lib/obsService.js", "types/**/*.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
10
types/jest-dom.d.ts
vendored
Normal file
10
types/jest-dom.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Jest DOM type definitions
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV: 'development' | 'production' | 'test';
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue