phase 13
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s

This commit is contained in:
Danilo Reyes
2025-11-02 14:48:03 -06:00
parent e5abcced74
commit 948fe591dc
28 changed files with 2123 additions and 54 deletions

View File

@@ -0,0 +1,69 @@
/**
* Groups API client
* Handles group creation, update, deletion
*/
import { apiClient } from './client';
export interface GroupCreateData {
name: string;
color: string;
annotation?: string;
image_ids: string[];
}
export interface GroupUpdateData {
name?: string;
color?: string;
annotation?: string;
}
export interface Group {
id: string;
board_id: string;
name: string;
color: string;
annotation: string | null;
member_count: number;
created_at: string;
updated_at: string;
}
/**
* Create a new group
*/
export async function createGroup(boardId: string, data: GroupCreateData): Promise<Group> {
return apiClient.post<Group>(`/api/boards/${boardId}/groups`, data);
}
/**
* List all groups on a board
*/
export async function listGroups(boardId: string): Promise<Group[]> {
return apiClient.get<Group[]>(`/api/boards/${boardId}/groups`);
}
/**
* Get a specific group
*/
export async function getGroup(boardId: string, groupId: string): Promise<Group> {
return apiClient.get<Group>(`/api/boards/${boardId}/groups/${groupId}`);
}
/**
* Update group metadata
*/
export async function updateGroup(
boardId: string,
groupId: string,
data: GroupUpdateData
): Promise<Group> {
return apiClient.patch<Group>(`/api/boards/${boardId}/groups/${groupId}`, data);
}
/**
* Delete a group (ungroups all members)
*/
export async function deleteGroup(boardId: string, groupId: string): Promise<void> {
await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`);
}

View File

@@ -0,0 +1,107 @@
<script lang="ts">
/**
* Group visual indicator for canvas
* Draws visual borders and labels for grouped images
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import type { Group } from '$lib/api/groups';
export let layer: Konva.Layer | null = null;
export let group: Group;
export let getGroupBounds: () => { x: number; y: number; width: number; height: number } | null;
let groupVisual: Konva.Group | null = null;
onMount(() => {
if (!layer) return;
// Create group visual
groupVisual = new Konva.Group({
listening: false,
name: `group-visual-${group.id}`,
});
layer.add(groupVisual);
updateVisual();
});
onDestroy(() => {
if (groupVisual) {
groupVisual.destroy();
groupVisual = null;
}
if (layer) {
layer.batchDraw();
}
});
/**
* Update group visual based on member positions
*/
export function updateVisual() {
if (!groupVisual || !layer) return;
// Clear existing visuals
groupVisual.destroyChildren();
const bounds = getGroupBounds();
if (!bounds) {
layer.batchDraw();
return;
}
// Draw group border
const border = new Konva.Rect({
x: bounds.x - 10,
y: bounds.y - 10,
width: bounds.width + 20,
height: bounds.height + 20,
stroke: group.color,
strokeWidth: 3,
dash: [10, 5],
cornerRadius: 8,
listening: false,
});
groupVisual.add(border);
// Draw group label
const labelBg = new Konva.Rect({
x: bounds.x - 10,
y: bounds.y - 35,
height: 24,
fill: group.color,
cornerRadius: 4,
listening: false,
});
const labelText = new Konva.Text({
x: bounds.x - 5,
y: bounds.y - 31,
text: group.name,
fontSize: 14,
fontStyle: 'bold',
fill: '#ffffff',
listening: false,
});
// Adjust background width to fit text
labelBg.width(labelText.width() + 10);
groupVisual.add(labelBg);
groupVisual.add(labelText);
// Move to bottom so it doesn't cover images
groupVisual.moveToBottom();
layer.batchDraw();
}
// Reactive updates
$: if (group && groupVisual) {
updateVisual();
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -0,0 +1,118 @@
/**
* Group move operations
* Move all images in a group together as a unit
*/
import type Konva from 'konva';
export interface GroupMoveOptions {
animate?: boolean;
onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void;
}
/**
* Move all images in a group by delta
*/
export function moveGroupBy(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[],
groupId: string,
deltaX: number,
deltaY: number,
options: GroupMoveOptions = {}
): void {
const { animate = false, onMoveComplete } = options;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const newX = image.x() + deltaX;
const newY = image.y() + deltaY;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw
const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onMoveComplete) {
onMoveComplete(groupId, deltaX, deltaY);
}
}
/**
* Move group to specific position (aligns top-left)
*/
export function moveGroupTo(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[],
groupId: string,
targetX: number,
targetY: number,
options: GroupMoveOptions = {}
): void {
// Find current top-left
let minX = Infinity;
let minY = Infinity;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
});
if (!isFinite(minX) || !isFinite(minY)) return;
const deltaX = targetX - minX;
const deltaY = targetY - minY;
moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options);
}
/**
* Get group bounding box
*/
export function getGroupBounds(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[]
): { x: number; y: number; width: number; height: number } | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
maxX = Math.max(maxX, box.x + box.width);
maxY = Math.max(maxY, box.y + box.height);
});
if (!isFinite(minX) || !isFinite(minY)) return null;
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}

View File

@@ -0,0 +1,83 @@
/**
* Group operations for canvas images
* Create groups from selected images
*/
import type { Group } from '$lib/api/groups';
export interface CreateGroupOptions {
name: string;
color: string;
annotation?: string;
onGroupCreate?: (group: Group) => void;
}
/**
* Create group from selected images
*/
export async function createGroupFromSelection(
selectedIds: string[],
boardId: string,
options: CreateGroupOptions
): Promise<Group | null> {
if (selectedIds.length === 0) {
return null;
}
const { createGroup } = await import('$lib/api/groups');
try {
const group = await createGroup(boardId, {
name: options.name,
color: options.color,
annotation: options.annotation,
image_ids: selectedIds,
});
if (options.onGroupCreate) {
options.onGroupCreate(group);
}
return group;
} catch (error) {
console.error('Failed to create group:', error);
return null;
}
}
/**
* Check if all selected images can be grouped
*/
export function canCreateGroup(selectedIds: string[]): boolean {
return selectedIds.length >= 1;
}
/**
* Get group color suggestions
*/
export function getGroupColorSuggestions(): string[] {
return [
'#FF5733', // Red
'#3B82F6', // Blue
'#10B981', // Green
'#F59E0B', // Yellow
'#8B5CF6', // Purple
'#EC4899', // Pink
'#14B8A6', // Teal
'#F97316', // Orange
];
}
/**
* Generate default group name
*/
export function generateDefaultGroupName(existingGroups: Group[]): string {
const baseName = 'Group';
let counter = existingGroups.length + 1;
while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) {
counter++;
}
return `${baseName} ${counter}`;
}

View File

@@ -0,0 +1,58 @@
/**
* Ungroup operations
* Remove images from groups
*/
export interface UngroupOptions {
onUngroupComplete?: (imageIds: string[], groupId: string) => void;
}
/**
* Ungroup images (remove from group)
*/
export async function ungroupImages(
boardId: string,
groupId: string,
options: UngroupOptions = {}
): Promise<boolean> {
const { deleteGroup } = await import('$lib/api/groups');
try {
await deleteGroup(boardId, groupId);
if (options.onUngroupComplete) {
// Note: We'd need to track which images were in the group
options.onUngroupComplete([], groupId);
}
return true;
} catch (error) {
console.error('Failed to ungroup:', error);
return false;
}
}
/**
* Remove specific images from group
*/
export async function removeImagesFromGroup(
boardId: string,
groupId: string,
imageIds: string[]
): Promise<boolean> {
// Update board images to remove group_id
const { apiClient } = await import('$lib/api/client');
try {
for (const imageId of imageIds) {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
group_id: null,
});
}
return true;
} catch (error) {
console.error('Failed to remove images from group:', error);
return false;
}
}

View File

@@ -0,0 +1,245 @@
<script lang="ts">
/**
* Color picker component for groups
* Allows selecting colors for group labels
*/
import { createEventDispatcher } from 'svelte';
import { getGroupColorSuggestions } from '$lib/canvas/operations/group';
export let selectedColor: string = '#3B82F6';
export let show: boolean = false;
const dispatch = createEventDispatcher();
const colorSuggestions = getGroupColorSuggestions();
let customColor = selectedColor;
function handleColorSelect(color: string) {
selectedColor = color;
customColor = color;
dispatch('select', { color });
show = false;
}
function handleCustomColorChange(event: Event) {
const color = (event.target as HTMLInputElement).value;
customColor = color;
}
function handleCustomColorSelect() {
selectedColor = customColor;
dispatch('select', { color: customColor });
show = false;
}
function handleClose() {
show = false;
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleClose();
}
}
</script>
{#if show}
<div
class="color-picker-backdrop"
on:click={handleBackdropClick}
on:keydown={(e) => e.key === 'Escape' && handleClose()}
role="button"
tabindex="-1"
>
<div class="color-picker" role="dialog">
<div class="picker-header">
<h4>Choose Color</h4>
<button class="close-button" on:click={handleClose}>×</button>
</div>
<div class="color-presets">
{#each colorSuggestions as color}
<button
class="color-swatch"
class:selected={selectedColor === color}
style="background-color: {color}"
on:click={() => handleColorSelect(color)}
title={color}
>
{#if selectedColor === color}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</button>
{/each}
</div>
<div class="custom-color">
<label for="custom-color-input">Custom Color</label>
<div class="custom-color-input">
<input
id="custom-color-input"
type="color"
bind:value={customColor}
on:change={handleCustomColorChange}
/>
<input
type="text"
value={customColor}
on:input={handleCustomColorChange}
placeholder="#RRGGBB"
maxlength="7"
/>
<button class="button-small" on:click={handleCustomColorSelect}>Apply</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.color-picker-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.color-picker {
background-color: var(--color-bg, #ffffff);
border-radius: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 320px;
width: 90%;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.picker-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.close-button {
background: none;
border: none;
font-size: 1.75rem;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.close-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
}
.color-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
padding: 1rem;
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.color-swatch:hover {
transform: scale(1.1);
border-color: rgba(0, 0, 0, 0.2);
}
.color-swatch.selected {
border-color: var(--color-text, #111827);
box-shadow: 0 0 0 2px var(--color-bg, #ffffff);
}
.custom-color {
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.custom-color label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.5rem;
}
.custom-color-input {
display: flex;
gap: 0.5rem;
align-items: center;
}
input[type='color'] {
width: 40px;
height: 40px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
cursor: pointer;
}
input[type='text'] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: monospace;
}
.button-small {
padding: 0.5rem 0.75rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.button-small:hover {
background-color: var(--color-primary-hover, #2563eb);
}
</style>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
/**
* Group annotation UI component
* Displays and edits group name, color, and annotation
*/
import { createEventDispatcher } from 'svelte';
import type { Group } from '$lib/api/groups';
export let group: Group;
export let editing: boolean = false;
const dispatch = createEventDispatcher();
let editName = group.name;
let editAnnotation = group.annotation || '';
function handleSave() {
dispatch('save', {
name: editName,
annotation: editAnnotation || null,
});
editing = false;
}
function handleCancel() {
editName = group.name;
editAnnotation = group.annotation || '';
editing = false;
}
function handleEdit() {
editing = true;
}
function handleDelete() {
dispatch('delete');
}
function handleColorChange() {
dispatch('color-change');
}
</script>
<div class="group-annotation" style="border-left: 4px solid {group.color}">
<div class="annotation-header">
<button
class="color-indicator"
style="background-color: {group.color}"
on:click={handleColorChange}
title="Change color"
/>
{#if editing}
<input
type="text"
bind:value={editName}
class="name-input"
placeholder="Group name"
maxlength="255"
/>
{:else}
<h4 class="group-name" on:dblclick={handleEdit}>{group.name}</h4>
{/if}
<div class="header-actions">
<span class="member-count" title="{group.member_count} images">
{group.member_count}
</span>
{#if !editing}
<button class="icon-button" on:click={handleEdit} title="Edit group"></button>
<button class="icon-button delete" on:click={handleDelete} title="Delete group"> × </button>
{/if}
</div>
</div>
<div class="annotation-body">
{#if editing}
<textarea
bind:value={editAnnotation}
class="annotation-input"
placeholder="Add annotation..."
maxlength="10000"
rows="3"
/>
<div class="edit-actions">
<button class="button button-secondary" on:click={handleCancel}>Cancel</button>
<button class="button button-primary" on:click={handleSave}>Save</button>
</div>
{:else if group.annotation}
<p class="annotation-text" on:dblclick={handleEdit}>{group.annotation}</p>
{:else}
<p class="annotation-empty" on:dblclick={handleEdit}>No annotation</p>
{/if}
</div>
</div>
<style>
.group-annotation {
background-color: var(--color-bg, #ffffff);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
}
.annotation-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.color-indicator {
width: 24px;
height: 24px;
border-radius: 0.375rem;
cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
padding: 0;
}
.color-indicator:hover {
transform: scale(1.1);
}
.group-name {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
cursor: pointer;
}
.group-name:hover {
color: var(--color-primary, #3b82f6);
}
.name-input {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
.icon-button {
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
cursor: pointer;
border-radius: 0.25rem;
font-size: 1.125rem;
color: var(--color-text-secondary, #6b7280);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
color: var(--color-text, #374151);
}
.icon-button.delete:hover {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #ef4444);
}
.annotation-body {
margin-top: 0.75rem;
}
.annotation-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text-secondary, #6b7280);
white-space: pre-wrap;
cursor: pointer;
}
.annotation-text:hover {
color: var(--color-text, #374151);
}
.annotation-empty {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
cursor: pointer;
}
.annotation-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
.button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.button-secondary {
background-color: var(--color-bg-secondary, #f3f4f6);
color: var(--color-text, #374151);
border-color: var(--color-border, #d1d5db);
}
.button-secondary:hover {
background-color: var(--color-bg-hover, #e5e7eb);
}
.button-primary {
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
}
.button-primary:hover {
background-color: var(--color-primary-hover, #2563eb);
}
</style>

View File

@@ -0,0 +1,158 @@
/**
* Groups store for managing image groups
* Handles group state and operations
*/
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
import * as groupsApi from '$lib/api/groups';
import type { Group, GroupCreateData, GroupUpdateData } from '$lib/api/groups';
export interface GroupsState {
groups: Group[];
loading: boolean;
error: string | null;
}
const DEFAULT_STATE: GroupsState = {
groups: [],
loading: false,
error: null,
};
/**
* Create groups store
*/
function createGroupsStore() {
const { subscribe, set, update }: Writable<GroupsState> = writable(DEFAULT_STATE);
return {
subscribe,
set,
update,
/**
* Load groups for a board
*/
load: async (boardId: string) => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const groups = await groupsApi.listGroups(boardId);
set({ groups, loading: false, error: null });
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to load groups',
}));
}
},
/**
* Create a new group
*/
create: async (boardId: string, data: GroupCreateData): Promise<Group | null> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const group = await groupsApi.createGroup(boardId, data);
update((state) => ({
groups: [group, ...state.groups],
loading: false,
error: null,
}));
return group;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to create group',
}));
return null;
}
},
/**
* Update a group
*/
updateGroup: async (
boardId: string,
groupId: string,
data: GroupUpdateData
): Promise<Group | null> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const group = await groupsApi.updateGroup(boardId, groupId, data);
update((state) => ({
groups: state.groups.map((g) => (g.id === groupId ? group : g)),
loading: false,
error: null,
}));
return group;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to update group',
}));
return null;
}
},
/**
* Delete a group
*/
delete: async (boardId: string, groupId: string): Promise<boolean> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
await groupsApi.deleteGroup(boardId, groupId);
update((state) => ({
groups: state.groups.filter((g) => g.id !== groupId),
loading: false,
error: null,
}));
return true;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to delete group',
}));
return false;
}
},
/**
* Get group by ID
*/
getById: (groupId: string): Group | null => {
let result: Group | null = null;
const unsubscribe = subscribe((state) => {
result = state.groups.find((g) => g.id === groupId) || null;
});
unsubscribe();
return result;
},
/**
* Clear all groups
*/
clear: () => {
set(DEFAULT_STATE);
},
};
}
export const groups = createGroupsStore();
// Derived stores
export const groupsLoading = derived(groups, ($groups) => $groups.loading);
export const groupsError = derived(groups, ($groups) => $groups.error);
export const groupsList = derived(groups, ($groups) => $groups.groups);
export const groupCount = derived(groups, ($groups) => $groups.groups.length);

View File

@@ -0,0 +1,219 @@
/**
* Tests for grouping operations
* Tests group creation, moving groups, ungrouping
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { get } from 'svelte/store';
import type { Group } from '$lib/api/groups';
import {
createGroupFromSelection,
canCreateGroup,
getGroupColorSuggestions,
generateDefaultGroupName,
} from '$lib/canvas/operations/group';
import { ungroupImages, removeImagesFromGroup } from '$lib/canvas/operations/ungroup';
import { groups, groupsLoading, groupsError, groupCount } from '$lib/stores/groups';
// Mock API
vi.mock('$lib/api/groups', () => ({
createGroup: vi.fn().mockResolvedValue({
id: 'group-1',
board_id: 'board-1',
name: 'Test Group',
color: '#FF5733',
annotation: 'Test',
member_count: 2,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}),
listGroups: vi.fn().mockResolvedValue([]),
updateGroup: vi.fn().mockResolvedValue({
id: 'group-1',
name: 'Updated',
color: '#00FF00',
}),
deleteGroup: vi.fn().mockResolvedValue(undefined),
}));
describe('Group Creation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('can create group from selection', () => {
const selectedIds = ['img1', 'img2'];
expect(canCreateGroup(selectedIds)).toBe(true);
});
it('cannot create group with no selection', () => {
expect(canCreateGroup([])).toBe(false);
});
it('creates group from selection', async () => {
const selectedIds = ['img1', 'img2'];
const group = await createGroupFromSelection(selectedIds, 'board-1', {
name: 'Test Group',
color: '#FF5733',
annotation: 'Test annotation',
});
expect(group).not.toBeNull();
expect(group?.name).toBe('Test Group');
});
it('calls callback on group creation', async () => {
const callback = vi.fn();
await createGroupFromSelection(['img1', 'img2'], 'board-1', {
name: 'Test Group',
color: '#FF5733',
onGroupCreate: callback,
});
expect(callback).toHaveBeenCalled();
});
it('generates default group names', () => {
const existingGroups: Group[] = [
{
id: '1',
board_id: 'board-1',
name: 'Group 1',
color: '#FF5733',
annotation: null,
member_count: 2,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: '2',
board_id: 'board-1',
name: 'Group 2',
color: '#FF5733',
annotation: null,
member_count: 3,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
const newName = generateDefaultGroupName(existingGroups);
expect(newName).toBe('Group 3');
});
it('provides color suggestions', () => {
const colors = getGroupColorSuggestions();
expect(colors).toBeInstanceOf(Array);
expect(colors.length).toBeGreaterThan(0);
expect(colors[0]).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
});
describe('Groups Store', () => {
beforeEach(() => {
groups.clear();
vi.clearAllMocks();
});
it('starts with empty state', () => {
const state = get(groups);
expect(state.groups).toEqual([]);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
});
it('loads groups', async () => {
await groups.load('board-1');
expect(get(groupsLoading)).toBe(false);
});
it('creates group', async () => {
const groupData = {
name: 'New Group',
color: '#FF5733',
image_ids: ['img1', 'img2'],
};
const group = await groups.create('board-1', groupData);
expect(group).not.toBeNull();
expect(get(groupCount)).toBe(1);
});
it('handles creation error', async () => {
const { createGroup } = await import('$lib/api/groups');
vi.mocked(createGroup).mockRejectedValueOnce(new Error('API Error'));
const group = await groups.create('board-1', {
name: 'Test',
color: '#FF5733',
image_ids: ['img1'],
});
expect(group).toBeNull();
expect(get(groupsError)).toBeTruthy();
});
it('deletes group', async () => {
// First create a group
await groups.create('board-1', {
name: 'Test',
color: '#FF5733',
image_ids: ['img1'],
});
expect(get(groupCount)).toBe(1);
// Then delete it
await groups.delete('board-1', 'group-1');
expect(get(groupCount)).toBe(0);
});
it('clears all groups', () => {
groups.clear();
const state = get(groups);
expect(state.groups).toEqual([]);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
});
});
describe('Ungroup Operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ungroups images', async () => {
const result = await ungroupImages('board-1', 'group-1');
expect(result).toBe(true);
});
it('handles ungroup error', async () => {
const { deleteGroup } = await import('$lib/api/groups');
vi.mocked(deleteGroup).mockRejectedValueOnce(new Error('API Error'));
const result = await ungroupImages('board-1', 'group-1');
expect(result).toBe(false);
});
it('removes specific images from group', async () => {
vi.mock('$lib/api/client', () => ({
apiClient: {
patch: vi.fn().mockResolvedValue({}),
},
}));
const result = await removeImagesFromGroup('board-1', 'group-1', ['img1', 'img2']);
expect(result).toBe(true);
});
});