Add OBS group management feature #3

Merged
deco merged 14 commits from ui-improvements into main 2025-07-20 21:46:26 +03:00
14 changed files with 98 additions and 56 deletions
Showing only changes of commit 2c338fd83a - Show all commits

View file

@ -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

View file

@ -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(

View file

@ -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);

View file

@ -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;

View file

@ -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');

View file

@ -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;

View file

@ -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', () => {

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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(

View file

@ -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]);
}

View file

@ -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
View 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';
}
}
}