From 5d82cc910b4a930e6902d7f53cd323756523b844 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Wed, 31 Dec 2025 03:05:09 +1030 Subject: [PATCH] feat: add testing functionality for custom formats - Implemented server-side logic for loading and managing tests in custom formats. - Created new page for editing existing tests with form handling. - Developed a reusable TestForm component for creating and editing test cases. - Added functionality to create new tests with validation and error handling. - Integrated layer permission checks for writing to base layer. - Enhanced user experience with modals for save and delete actions. --- src/lib/client/ui/form/IconCheckbox.svelte | 56 ++- .../client/ui/table/ExpandableTable.svelte | 14 +- .../pcd/queries/customFormats/conditions.ts | 209 ++++++++ .../pcd/queries/customFormats/evaluator.ts | 473 ++++++++++++++++++ .../server/pcd/queries/customFormats/index.ts | 17 +- .../pcd/queries/customFormats/testCreate.ts | 57 +++ .../pcd/queries/customFormats/testDelete.ts | 51 ++ .../pcd/queries/customFormats/testUpdate.ts | 67 +++ .../server/pcd/queries/customFormats/tests.ts | 60 +++ .../server/pcd/queries/customFormats/types.ts | 16 + src/lib/server/pcd/schema.ts | 15 + src/routes/api/regex101/[id]/+server.ts | 5 + .../[databaseId]/[id]/+layout.svelte | 34 ++ .../[databaseId]/[id]/+page.server.ts | 7 + .../[databaseId]/[id]/general/+page.server.ts | 41 ++ .../[databaseId]/[id]/general/+page.svelte | 15 + .../[databaseId]/[id]/testing/+page.server.ts | 192 +++++++ .../[databaseId]/[id]/testing/+page.svelte | 429 ++++++++++++++++ .../[id]/testing/[testId]/+page.server.ts | 167 +++++++ .../[id]/testing/[testId]/+page.svelte | 33 ++ .../[id]/testing/components/TestForm.svelte | 310 ++++++++++++ .../[id]/testing/new/+page.server.ts | 100 ++++ .../[id]/testing/new/+page.svelte | 32 ++ 23 files changed, 2394 insertions(+), 6 deletions(-) create mode 100644 src/lib/server/pcd/queries/customFormats/conditions.ts create mode 100644 src/lib/server/pcd/queries/customFormats/evaluator.ts create mode 100644 src/lib/server/pcd/queries/customFormats/testCreate.ts create mode 100644 src/lib/server/pcd/queries/customFormats/testDelete.ts create mode 100644 src/lib/server/pcd/queries/customFormats/testUpdate.ts create mode 100644 src/lib/server/pcd/queries/customFormats/tests.ts create mode 100644 src/routes/custom-formats/[databaseId]/[id]/+layout.svelte create mode 100644 src/routes/custom-formats/[databaseId]/[id]/+page.server.ts create mode 100644 src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts create mode 100644 src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/+page.server.ts create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/+page.svelte create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts create mode 100644 src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte diff --git a/src/lib/client/ui/form/IconCheckbox.svelte b/src/lib/client/ui/form/IconCheckbox.svelte index 59370be..dde175c 100644 --- a/src/lib/client/ui/form/IconCheckbox.svelte +++ b/src/lib/client/ui/form/IconCheckbox.svelte @@ -56,7 +56,7 @@ {/if} -{:else} +{:else if color === 'green'} +{:else if color === 'red'} + +{:else if color === 'blue'} + +{:else} + + + + + + {#if !data.parserAvailable} +
+ +
+

+ Parser service unavailable +

+

+ Test results cannot be evaluated. Start the parser microservice to see pass/fail status. +

+
+
+ {/if} + + + {#if data.tests.length === 0} +
+

+ No test cases yet. Add a test to verify this custom format works correctly. +

+
+ {:else} + + + {#if column.key === 'title'} + {row.title} + {#if row.description} +

{row.description}

+ {/if} + {:else if column.key === 'should_match'} + {#if row.should_match} + Should Match + {:else} + Shouldn't Match + {/if} + {:else if column.key === 'type'} + {row.type} + {:else if column.key === 'result'} + {#if row.result === 'pass'} +
+
+ +
+
+ {:else if row.result === 'fail'} +
+
+ +
+
+ {:else} +
+
+ ? +
+
+ {/if} + {/if} +
+ + + {@const conditionTypeLabels = { + 'release_title': 'Release Title', + 'source': 'Source', + 'resolution': 'Resolution', + 'quality_modifier': 'Quality Modifier', + 'language': 'Language', + 'release_group': 'Release Group', + 'release_type': 'Release Type', + 'year': 'Year', + 'edition': 'Edition', + 'indexer_flag': 'Indexer Flag', + 'size': 'Size' + }} + {@const groupedConditions = row.conditions.reduce((acc, c) => { + if (!acc[c.conditionType]) acc[c.conditionType] = []; + acc[c.conditionType].push(c); + return acc; + }, {})} + {@const conditionTypes = Object.keys(groupedConditions)} + {@const allRequiredPass = row.conditions.filter(c => c.required).every(c => c.passes)} + {@const optionalConditions = row.conditions.filter(c => !c.required)} + {@const optionalPass = optionalConditions.length === 0 || optionalConditions.some(c => c.passes)} + +
+ {#if row.conditions.length > 0} +
+ + + + + + + + + + + + + + + + {#each conditionTypes as conditionType, typeIndex} + {@const conditions = groupedConditions[conditionType]} + {#each conditions as condition, condIndex} + + {#if condIndex === 0} + + {/if} + + + + + {#if condIndex === 0} + {@const requiredPass = conditions.filter(c => c.required).every(c => c.passes)} + {@const optionalConditions = conditions.filter(c => !c.required)} + {@const optionalPass = optionalConditions.length === 0 || optionalConditions.some(c => c.passes)} + {@const typePass = requiredPass && optionalPass} + + {/if} + {#if typeIndex === 0 && condIndex === 0} + + + + {/if} + + {/each} + {/each} + +
TypeConditionExpectedActualPassType PassExpectedActualResult
+ {conditionTypeLabels[conditionType] || conditionType} + +
+ {condition.conditionName} + {#if condition.required} + Required + {/if} +
+
+ {condition.expected} + + {condition.actual} + + {#if condition.passes} +
+ +
+ {:else} +
+ +
+ {/if} +
+ {#if typePass} +
+ +
+ {:else} +
+ +
+ {/if} +
+ {#if row.should_match} +
+ +
+
MATCH
+ {:else} +
+ +
+
NO MATCH
+ {/if} +
+ {#if row.actual_match} +
+ +
+
MATCH
+ {:else} +
+ +
+
NO MATCH
+ {/if} +
+ {#if row.result === 'pass'} +
+ +
+
PASS
+ {:else if row.result === 'fail'} +
+ +
+
FAIL
+ {:else} +
+ ? +
+
UNKNOWN
+ {/if} +
+
+ + + {#if row.parsed} +
+ + Parsed Values + +
+
+ Source: + {row.parsed.source} +
+
+ Resolution: + {row.parsed.resolution} +
+
+ Modifier: + {row.parsed.modifier} +
+
+ Languages: + {row.parsed.languages.length > 0 ? row.parsed.languages.join(', ') : 'None'} +
+ {#if row.parsed.releaseGroup} +
+ Release Group: + {row.parsed.releaseGroup} +
+ {/if} + {#if row.parsed.year} +
+ Year: + {row.parsed.year} +
+ {/if} + {#if row.parsed.edition} +
+ Edition: + {row.parsed.edition} +
+ {/if} + {#if row.parsed.releaseType} +
+ Release Type: + {row.parsed.releaseType} +
+ {/if} +
+
+ {/if} + {:else if !row.parsed} +
+ Parser unavailable - unable to evaluate conditions +
+ {/if} +
+
+ + +
+ +
+ + + + +
+
+
+
+ {/if} + + + + diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts new file mode 100644 index 0000000..ddd5f2f --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts @@ -0,0 +1,167 @@ +import { error, redirect, fail } from '@sveltejs/kit'; +import type { ServerLoad, Actions } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; +import { canWriteToBase, type OperationLayer } from '$pcd/writer.ts'; +import * as customFormatQueries from '$pcd/queries/customFormats/index.ts'; + +export const load: ServerLoad = async ({ params }) => { + const { databaseId, id, testId } = params; + + if (!databaseId || !id || !testId) { + throw error(400, 'Missing required parameters'); + } + + const currentDatabaseId = parseInt(databaseId, 10); + const formatId = parseInt(id, 10); + const currentTestId = parseInt(testId, 10); + + if (isNaN(currentDatabaseId) || isNaN(formatId) || isNaN(currentTestId)) { + throw error(400, 'Invalid parameters'); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + throw error(500, 'Database cache not available'); + } + + const format = await customFormatQueries.getById(cache, formatId); + if (!format) { + throw error(404, 'Custom format not found'); + } + + const test = await customFormatQueries.getTestById(cache, currentTestId); + if (!test) { + throw error(404, 'Test not found'); + } + + return { + format, + test, + canWriteToBase: canWriteToBase(currentDatabaseId) + }; +}; + +export const actions: Actions = { + update: async ({ request, params }) => { + const { databaseId, id, testId } = params; + + if (!databaseId || !id || !testId) { + return fail(400, { error: 'Missing required parameters' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + const currentTestId = parseInt(testId, 10); + + if (isNaN(currentDatabaseId) || isNaN(currentTestId)) { + return fail(400, { error: 'Invalid parameters' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + // Get current test for value guards + const current = await customFormatQueries.getTestById(cache, currentTestId); + if (!current) { + return fail(404, { error: 'Test not found' }); + } + + const formData = await request.formData(); + + const title = formData.get('title') as string; + const type = formData.get('type') as 'movie' | 'series'; + const shouldMatch = formData.get('shouldMatch') === '1'; + const description = (formData.get('description') as string) || null; + const formatName = formData.get('formatName') as string; + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + if (!title?.trim()) { + return fail(400, { error: 'Title is required' }); + } + + if (type !== 'movie' && type !== 'series') { + return fail(400, { error: 'Invalid type' }); + } + + if (!formatName) { + return fail(400, { error: 'Format name is required' }); + } + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + const result = await customFormatQueries.updateTest({ + databaseId: currentDatabaseId, + layer, + formatName, + current, + input: { + title: title.trim(), + type, + should_match: shouldMatch, + description: description?.trim() || null + } + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to update test' }); + } + + throw redirect(303, `/custom-formats/${databaseId}/${id}/testing`); + }, + + delete: async ({ request, params }) => { + const { databaseId, id, testId } = params; + + if (!databaseId || !id || !testId) { + return fail(400, { error: 'Missing required parameters' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + const currentTestId = parseInt(testId, 10); + + if (isNaN(currentDatabaseId) || isNaN(currentTestId)) { + return fail(400, { error: 'Invalid parameters' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + // Get current test for value guards + const current = await customFormatQueries.getTestById(cache, currentTestId); + if (!current) { + return fail(404, { error: 'Test not found' }); + } + + const formData = await request.formData(); + const formatName = formData.get('formatName') as string; + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + if (!formatName) { + return fail(400, { error: 'Format name is required' }); + } + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + const result = await customFormatQueries.deleteTest({ + databaseId: currentDatabaseId, + layer, + formatName, + current + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to delete test' }); + } + + throw redirect(303, `/custom-formats/${databaseId}/${id}/testing`); + } +}; diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte new file mode 100644 index 0000000..d45d8fe --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte @@ -0,0 +1,33 @@ + + + + Edit Test - {data.format.name} - Profilarr + + + diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte new file mode 100644 index 0000000..b2ace2e --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte @@ -0,0 +1,310 @@ + + +
+ +
+

{pageTitle}

+

+ {pageDescription} +

+
+ +
{ + saving = true; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed'); + } else if (result.type === 'redirect') { + alertStore.add('success', mode === 'create' ? 'Test case created!' : 'Test case updated!'); + } + await update(); + saving = false; + }; + }} + > + + + + + + +
+
+ +
+ + +
+ + +
+
+ Media Type +
+
+ {#each typeOptions as option} + + {/each} +
+
+ + +
+
+ Expected Result +
+
+ {#each matchOptions as option} + + {/each} +
+
+ + +
+ + +
+
+ + +
+
+ {#if mode === 'edit'} + + {/if} +
+
+ + +
+
+
+
+ + + {#if mode === 'edit'} + + {/if} +
+ + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> + + + (showDeleteTargetModal = false)} + /> +{/if} diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts new file mode 100644 index 0000000..feb8689 --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts @@ -0,0 +1,100 @@ +import { error, redirect, fail } from '@sveltejs/kit'; +import type { ServerLoad, Actions } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; +import { canWriteToBase, type OperationLayer } from '$pcd/writer.ts'; +import * as customFormatQueries from '$pcd/queries/customFormats/index.ts'; + +export const load: ServerLoad = async ({ params }) => { + const { databaseId, id } = params; + + if (!databaseId || !id) { + throw error(400, 'Missing required parameters'); + } + + const currentDatabaseId = parseInt(databaseId, 10); + const formatId = parseInt(id, 10); + + if (isNaN(currentDatabaseId) || isNaN(formatId)) { + throw error(400, 'Invalid parameters'); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + throw error(500, 'Database cache not available'); + } + + const format = await customFormatQueries.getById(cache, formatId); + if (!format) { + throw error(404, 'Custom format not found'); + } + + return { + format, + canWriteToBase: canWriteToBase(currentDatabaseId) + }; +}; + +export const actions: Actions = { + default: async ({ request, params }) => { + const { databaseId, id } = params; + + if (!databaseId || !id) { + return fail(400, { error: 'Missing required parameters' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid parameters' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + const title = formData.get('title') as string; + const type = formData.get('type') as 'movie' | 'series'; + const shouldMatch = formData.get('shouldMatch') === '1'; + const description = (formData.get('description') as string) || null; + const formatName = formData.get('formatName') as string; + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + if (!title?.trim()) { + return fail(400, { error: 'Title is required' }); + } + + if (type !== 'movie' && type !== 'series') { + return fail(400, { error: 'Invalid type' }); + } + + if (!formatName) { + return fail(400, { error: 'Format name is required' }); + } + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + const result = await customFormatQueries.createTest({ + databaseId: currentDatabaseId, + layer, + formatName, + input: { + title: title.trim(), + type, + should_match: shouldMatch, + description: description?.trim() || null + } + }); + + if (!result.success) { + return fail(500, { error: result.error || 'Failed to create test' }); + } + + throw redirect(303, `/custom-formats/${databaseId}/${id}/testing`); + } +}; diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte new file mode 100644 index 0000000..24533cf --- /dev/null +++ b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte @@ -0,0 +1,32 @@ + + + + New Test - {data.format.name} - Profilarr + + +