From b82fea29295fc742db9716768c08e1bd2e04e392 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 13:55:47 -0400 Subject: [PATCH 01/19] Add Constraint discriminated-union types for validation refactor --- resources/ext.neowiki/src/domain/Constraint.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 resources/ext.neowiki/src/domain/Constraint.ts diff --git a/resources/ext.neowiki/src/domain/Constraint.ts b/resources/ext.neowiki/src/domain/Constraint.ts new file mode 100644 index 00000000..3a6c66ee --- /dev/null +++ b/resources/ext.neowiki/src/domain/Constraint.ts @@ -0,0 +1,9 @@ +export type Severity = 'error' | 'warning'; + +export type Constraint = + | { kind: 'required'; severity?: Severity } + | { kind: 'minLength'; value: number; severity?: Severity } + | { kind: 'maxLength'; value: number; severity?: Severity } + | { kind: 'uniqueItems'; severity?: Severity } + | { kind: 'cardinality'; max: number; severity?: Severity } + | { kind: 'enum'; allowedValues: string[]; severity?: Severity }; From 2c17385d092e1f8fe6cfb9656d47b2c3c62a4c23 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:02:16 -0400 Subject: [PATCH 02/19] Rename cardinality.max to cardinality.maxItems for clarity The 'maxItems' name is self-documenting about what's being capped (item count) and matches the JSON Schema convention for array-length bounds, distinguishing it from value-bound constraints like Number's minimum/maximum. --- resources/ext.neowiki/src/domain/Constraint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/ext.neowiki/src/domain/Constraint.ts b/resources/ext.neowiki/src/domain/Constraint.ts index 3a6c66ee..a6b319ff 100644 --- a/resources/ext.neowiki/src/domain/Constraint.ts +++ b/resources/ext.neowiki/src/domain/Constraint.ts @@ -5,5 +5,5 @@ export type Constraint = | { kind: 'minLength'; value: number; severity?: Severity } | { kind: 'maxLength'; value: number; severity?: Severity } | { kind: 'uniqueItems'; severity?: Severity } - | { kind: 'cardinality'; max: number; severity?: Severity } + | { kind: 'cardinality'; maxItems: number; severity?: Severity } | { kind: 'enum'; allowedValues: string[]; severity?: Severity }; From 9bc0662694f59f615cac6a7a90f784abd1f44565 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:04:07 -0400 Subject: [PATCH 03/19] Add isValueEmpty helper for per-Value-Type empty detection --- resources/ext.neowiki/src/domain/Value.ts | 14 +++++++ .../ext.neowiki/tests/domain/Value.spec.ts | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/resources/ext.neowiki/src/domain/Value.ts b/resources/ext.neowiki/src/domain/Value.ts index 732bc2f6..866806cc 100644 --- a/resources/ext.neowiki/src/domain/Value.ts +++ b/resources/ext.neowiki/src/domain/Value.ts @@ -86,6 +86,20 @@ export function newRelation( id: string | undefined, target: SubjectId | string ); } +export function isValueEmpty( value: Value | undefined ): boolean { + if ( value === undefined ) return true; + switch ( value.type ) { + case ValueType.String: + return value.parts.length === 0; + case ValueType.Number: + return false; + case ValueType.Boolean: + return false; + case ValueType.Relation: + return value.relations.length === 0; + } +} + export function relationValuesHaveSameTargets( a: RelationValue | undefined, b: RelationValue | undefined, diff --git a/resources/ext.neowiki/tests/domain/Value.spec.ts b/resources/ext.neowiki/tests/domain/Value.spec.ts index 5d4f0a94..7b76265e 100644 --- a/resources/ext.neowiki/tests/domain/Value.spec.ts +++ b/resources/ext.neowiki/tests/domain/Value.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + isValueEmpty, newBooleanValue, newNumberValue, newRelation, @@ -153,3 +154,43 @@ describe( 'relationValuesHaveSameTargets', () => { } ); } ); + +describe( 'isValueEmpty', () => { + + it( 'returns true for undefined', () => { + expect( isValueEmpty( undefined ) ).toBe( true ); + } ); + + it( 'returns true for StringValue with no parts', () => { + expect( isValueEmpty( newStringValue() ) ).toBe( true ); + } ); + + it( 'returns false for StringValue with one part', () => { + expect( isValueEmpty( newStringValue( 'hello' ) ) ).toBe( false ); + } ); + + it( 'returns false for NumberValue with zero', () => { + expect( isValueEmpty( newNumberValue( 0 ) ) ).toBe( false ); + } ); + + it( 'returns false for NumberValue with non-zero', () => { + expect( isValueEmpty( newNumberValue( 42 ) ) ).toBe( false ); + } ); + + it( 'returns false for BooleanValue (true)', () => { + expect( isValueEmpty( newBooleanValue( true ) ) ).toBe( false ); + } ); + + it( 'returns false for BooleanValue (false)', () => { + expect( isValueEmpty( newBooleanValue( false ) ) ).toBe( false ); + } ); + + it( 'returns true for RelationValue with no relations', () => { + expect( isValueEmpty( new RelationValue( [] ) ) ).toBe( true ); + } ); + + it( 'returns false for RelationValue with one relation', () => { + expect( isValueEmpty( new RelationValue( [ newRelation( undefined, 's11111111111111' ) ] ) ) ).toBe( false ); + } ); + +} ); From ed96369d60c38d4bafe837b67c6ec110639de457 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:06:52 -0400 Subject: [PATCH 04/19] Add optional severity field to ValueValidationError --- resources/ext.neowiki/src/domain/PropertyType.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/resources/ext.neowiki/src/domain/PropertyType.ts b/resources/ext.neowiki/src/domain/PropertyType.ts index 558cf445..fb6a6dcf 100644 --- a/resources/ext.neowiki/src/domain/PropertyType.ts +++ b/resources/ext.neowiki/src/domain/PropertyType.ts @@ -44,6 +44,11 @@ export interface ValueValidationError { */ source?: unknown; + /** + * Optional severity. Absent means error (default). + */ + severity?: 'error' | 'warning'; + } export type PropertyType = BasePropertyType; From 080079c839c067e8bdda8af0f1dc2c93a87259da Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:12:15 -0400 Subject: [PATCH 05/19] Add ConstraintInterpreter for declarative constraint evaluation Co-Authored-By: Claude Sonnet 4.6 --- .../src/domain/ConstraintInterpreter.ts | 79 +++++++ .../domain/ConstraintInterpreter.spec.ts | 200 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 resources/ext.neowiki/src/domain/ConstraintInterpreter.ts create mode 100644 resources/ext.neowiki/tests/domain/ConstraintInterpreter.spec.ts diff --git a/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts b/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts new file mode 100644 index 00000000..94a6dd8e --- /dev/null +++ b/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts @@ -0,0 +1,79 @@ +import type { Constraint } from '@/domain/Constraint'; +import { isValueEmpty, type StringValue, type Value, ValueType } from '@/domain/Value'; +import type { ValueValidationError } from '@/domain/PropertyType'; + +export function interpretConstraints( + constraints: Constraint[], + value: Value | undefined, +): ValueValidationError[] { + const errors: ValueValidationError[] = []; + for ( const constraint of constraints ) { + errors.push( ...evaluate( constraint, value ) ); + } + return errors; +} + +function evaluate( constraint: Constraint, value: Value | undefined ): ValueValidationError[] { + switch ( constraint.kind ) { + case 'required': + return isValueEmpty( value ) ? [ emit( 'required', constraint ) ] : []; + + case 'minLength': { + if ( value?.type !== ValueType.String ) return []; + const out: ValueValidationError[] = []; + for ( const part of ( value as StringValue ).parts ) { + if ( part.trim().length < constraint.value ) { + out.push( emit( 'min-length', constraint, { args: [ constraint.value ], source: part } ) ); + } + } + return out; + } + + case 'maxLength': { + if ( value?.type !== ValueType.String ) return []; + const out: ValueValidationError[] = []; + for ( const part of ( value as StringValue ).parts ) { + if ( part.trim().length > constraint.value ) { + out.push( emit( 'max-length', constraint, { args: [ constraint.value ], source: part } ) ); + } + } + return out; + } + + case 'uniqueItems': { + if ( value?.type !== ValueType.String ) return []; + const parts = ( value as StringValue ).parts; + return new Set( parts ).size !== parts.length ? [ emit( 'unique', constraint ) ] : []; + } + + case 'cardinality': { + if ( value?.type !== ValueType.String ) return []; + const parts = ( value as StringValue ).parts; + return parts.length > constraint.maxItems ? [ emit( 'single-value-only', constraint ) ] : []; + } + + case 'enum': { + if ( value?.type !== ValueType.String ) return []; + const allowed = new Set( constraint.allowedValues ); + const out: ValueValidationError[] = []; + for ( const part of ( value as StringValue ).parts ) { + if ( !allowed.has( part ) ) { + out.push( emit( 'invalid-option', constraint, { args: [ part ], source: part } ) ); + } + } + return out; + } + } +} + +function emit( + code: string, + constraint: Constraint, + extra: Partial> = {}, +): ValueValidationError { + const error: ValueValidationError = { code, ...extra }; + if ( constraint.severity !== undefined ) { + error.severity = constraint.severity; + } + return error; +} diff --git a/resources/ext.neowiki/tests/domain/ConstraintInterpreter.spec.ts b/resources/ext.neowiki/tests/domain/ConstraintInterpreter.spec.ts new file mode 100644 index 00000000..6b588c38 --- /dev/null +++ b/resources/ext.neowiki/tests/domain/ConstraintInterpreter.spec.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { interpretConstraints } from '@/domain/ConstraintInterpreter'; +import { newNumberValue, newRelation, newStringValue, RelationValue } from '@/domain/Value'; + +describe( 'interpretConstraints', () => { + + describe( 'required', () => { + + it( 'produces required error for empty StringValue', () => { + expect( interpretConstraints( [ { kind: 'required' } ], newStringValue() ) ) + .toEqual( [ { code: 'required' } ] ); + } ); + + it( 'produces required error for undefined value', () => { + expect( interpretConstraints( [ { kind: 'required' } ], undefined ) ) + .toEqual( [ { code: 'required' } ] ); + } ); + + it( 'produces no error for non-empty StringValue', () => { + expect( interpretConstraints( [ { kind: 'required' } ], newStringValue( 'a' ) ) ) + .toEqual( [] ); + } ); + + it( 'produces required error for empty RelationValue', () => { + expect( interpretConstraints( [ { kind: 'required' } ], new RelationValue( [] ) ) ) + .toEqual( [ { code: 'required' } ] ); + } ); + + it( 'produces no error for non-empty RelationValue', () => { + const value = new RelationValue( [ newRelation( undefined, 's11111111111111' ) ] ); + expect( interpretConstraints( [ { kind: 'required' } ], value ) ).toEqual( [] ); + } ); + + } ); + + describe( 'minLength', () => { + + it( 'produces error per part below threshold', () => { + expect( interpretConstraints( + [ { kind: 'minLength', value: 3 } ], + newStringValue( 'ab', 'abcd' ), + ) ).toEqual( [ { code: 'min-length', args: [ 3 ], source: 'ab' } ] ); + } ); + + it( 'produces no error when all parts meet threshold', () => { + expect( interpretConstraints( + [ { kind: 'minLength', value: 3 } ], + newStringValue( 'abc', 'abcd' ), + ) ).toEqual( [] ); + } ); + + it( 'measures trimmed length', () => { + // Note: newStringValue trims at construction; this confirms the interpreter ALSO trims. + // Construct a StringValue with an untrimmed part by hand to bypass newStringValue's trim. + const value = { type: 'string' as const, parts: [ ' ab ' ] }; + expect( interpretConstraints( + [ { kind: 'minLength', value: 3 } ], + value, + ) ).toEqual( [ { code: 'min-length', args: [ 3 ], source: ' ab ' } ] ); + } ); + + } ); + + describe( 'maxLength', () => { + + it( 'produces error per part above threshold', () => { + expect( interpretConstraints( + [ { kind: 'maxLength', value: 3 } ], + newStringValue( 'ab', 'abcd' ), + ) ).toEqual( [ { code: 'max-length', args: [ 3 ], source: 'abcd' } ] ); + } ); + + it( 'produces no error when all parts within threshold', () => { + expect( interpretConstraints( + [ { kind: 'maxLength', value: 5 } ], + newStringValue( 'ab', 'abcd' ), + ) ).toEqual( [] ); + } ); + + } ); + + describe( 'uniqueItems', () => { + + it( 'produces unique error when parts contain duplicates', () => { + expect( interpretConstraints( + [ { kind: 'uniqueItems' } ], + newStringValue( 'a', 'b', 'a' ), + ) ).toEqual( [ { code: 'unique' } ] ); + } ); + + it( 'produces no error when all parts are unique', () => { + expect( interpretConstraints( + [ { kind: 'uniqueItems' } ], + newStringValue( 'a', 'b', 'c' ), + ) ).toEqual( [] ); + } ); + + } ); + + describe( 'cardinality', () => { + + it( 'produces single-value-only when parts exceed maxItems', () => { + expect( interpretConstraints( + [ { kind: 'cardinality', maxItems: 1 } ], + newStringValue( 'a', 'b' ), + ) ).toEqual( [ { code: 'single-value-only' } ] ); + } ); + + it( 'produces no error when parts at or below maxItems', () => { + expect( interpretConstraints( + [ { kind: 'cardinality', maxItems: 1 } ], + newStringValue( 'a' ), + ) ).toEqual( [] ); + } ); + + } ); + + describe( 'enum', () => { + + it( 'produces invalid-option per part outside allowedValues', () => { + expect( interpretConstraints( + [ { kind: 'enum', allowedValues: [ 'a', 'b' ] } ], + newStringValue( 'a', 'x', 'b', 'y' ), + ) ).toEqual( [ + { code: 'invalid-option', args: [ 'x' ], source: 'x' }, + { code: 'invalid-option', args: [ 'y' ], source: 'y' }, + ] ); + } ); + + it( 'produces no error when all parts in allowedValues', () => { + expect( interpretConstraints( + [ { kind: 'enum', allowedValues: [ 'a', 'b', 'c' ] } ], + newStringValue( 'a', 'b' ), + ) ).toEqual( [] ); + } ); + + } ); + + describe( 'value-type guards', () => { + + it( 'skips minLength on NumberValue silently', () => { + expect( interpretConstraints( + [ { kind: 'minLength', value: 3 } ], + newNumberValue( 42 ), + ) ).toEqual( [] ); + } ); + + it( 'skips uniqueItems on undefined silently', () => { + expect( interpretConstraints( [ { kind: 'uniqueItems' } ], undefined ) ).toEqual( [] ); + } ); + + it( 'skips enum on RelationValue silently', () => { + const value = new RelationValue( [ newRelation( undefined, 's11111111111111' ) ] ); + expect( interpretConstraints( + [ { kind: 'enum', allowedValues: [ 'a' ] } ], + value, + ) ).toEqual( [] ); + } ); + + } ); + + describe( 'severity passthrough', () => { + + it( 'copies severity from constraint to error', () => { + expect( interpretConstraints( + [ { kind: 'required', severity: 'warning' } ], + newStringValue(), + ) ).toEqual( [ { code: 'required', severity: 'warning' } ] ); + } ); + + it( 'omits severity when not set on constraint', () => { + const errors = interpretConstraints( [ { kind: 'required' } ], newStringValue() ); + expect( errors[ 0 ] ).not.toHaveProperty( 'severity' ); + } ); + + } ); + + describe( 'multiple constraints', () => { + + it( 'concatenates errors in input order', () => { + // minLength then maxLength: each emits independently across parts + expect( interpretConstraints( + [ { kind: 'minLength', value: 3 }, { kind: 'maxLength', value: 5 } ], + newStringValue( 'ab', 'abcdefgh' ), + ) ).toEqual( [ + { code: 'min-length', args: [ 3 ], source: 'ab' }, + { code: 'max-length', args: [ 5 ], source: 'abcdefgh' }, + ] ); + } ); + + it( 'returns empty array when no constraints fail', () => { + expect( interpretConstraints( + [ { kind: 'required' }, { kind: 'minLength', value: 1 } ], + newStringValue( 'ok' ), + ) ).toEqual( [] ); + } ); + + } ); + +} ); From ad06aee1e0d11ee42b57bb4243f5dbe0a751f2d7 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:16:01 -0400 Subject: [PATCH 06/19] Backfill RelationType.validate regression tests --- .../domain/propertyTypes/Relation.spec.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/resources/ext.neowiki/tests/domain/propertyTypes/Relation.spec.ts b/resources/ext.neowiki/tests/domain/propertyTypes/Relation.spec.ts index 63d892b3..ef8aa52b 100644 --- a/resources/ext.neowiki/tests/domain/propertyTypes/Relation.spec.ts +++ b/resources/ext.neowiki/tests/domain/propertyTypes/Relation.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { newRelationProperty, RelationType } from '@/domain/propertyTypes/Relation'; import { PropertyName } from '@/domain/PropertyDefinition'; -import { newRelation, RelationValue } from '@/domain/Value'; +import { newRelation, RelationValue, Relation } from '@/domain/Value'; describe( 'RelationType', () => { @@ -84,3 +84,49 @@ describe( 'newRelationProperty', () => { expect( property.multiple ).toBe( false ); } ); } ); + +describe( 'RelationType.validate', () => { + + const type = new RelationType(); + + it( 'returns no errors when not required and value is undefined', () => { + const property = newRelationProperty(); + expect( type.validate( undefined, property ) ).toEqual( [] ); + } ); + + it( 'returns required error when required and value is undefined', () => { + const property = newRelationProperty( { required: true } ); + expect( type.validate( undefined, property ) ).toEqual( [ { code: 'required' } ] ); + } ); + + it( 'returns required error when required and value has empty relations', () => { + const property = newRelationProperty( { required: true } ); + expect( type.validate( new RelationValue( [] ), property ) ).toEqual( [ { code: 'required' } ] ); + } ); + + it( 'returns no errors when relations all have valid SubjectIds', () => { + const property = newRelationProperty(); + const value = new RelationValue( [ + newRelation( undefined, 's11111111111111' ), + newRelation( undefined, 's22222222222222' ), + ] ); + expect( type.validate( value, property ) ).toEqual( [] ); + } ); + + it( 'returns one invalid-subject-id error per malformed target', () => { + const property = newRelationProperty(); + // Construct Relation directly with mock SubjectId objects to bypass validation + // in the SubjectId constructor + const invalidSubjectIdMock1 = { text: 'not-a-valid-id' }; + const invalidSubjectIdMock2 = { text: 'also-bad' }; + const value = new RelationValue( [ + new Relation( undefined, invalidSubjectIdMock1 as any ), + new Relation( undefined, invalidSubjectIdMock2 as any ), + ] ); + expect( type.validate( value, property ) ).toEqual( [ + { code: 'invalid-subject-id', args: [ 'not-a-valid-id' ] }, + { code: 'invalid-subject-id', args: [ 'also-bad' ] }, + ] ); + } ); + +} ); From d810523fee8b60d6c7dff94ab62b05851ad8ed5a Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:17:50 -0400 Subject: [PATCH 07/19] Add delegating validate, getConstraints, validateValue to BasePropertyType --- .../ext.neowiki/src/domain/PropertyType.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/resources/ext.neowiki/src/domain/PropertyType.ts b/resources/ext.neowiki/src/domain/PropertyType.ts index fb6a6dcf..1dc2f761 100644 --- a/resources/ext.neowiki/src/domain/PropertyType.ts +++ b/resources/ext.neowiki/src/domain/PropertyType.ts @@ -1,6 +1,8 @@ import { PropertyDefinition } from '@/domain/PropertyDefinition'; import type { Value } from '@/domain/Value'; import { ValueType } from '@/domain/Value'; +import type { Constraint } from '@/domain/Constraint'; +import { interpretConstraints } from '@/domain/ConstraintInterpreter'; export abstract class BasePropertyType

{ @@ -22,8 +24,20 @@ export abstract class BasePropertyType

Date: Mon, 4 May 2026 14:22:34 -0400 Subject: [PATCH 08/19] Add curly braces to single-line if-return guards The Wikimedia ESLint config (curly rule) requires braces around all if-statement bodies. Apply to the early-return guards in isValueEmpty and the value-type guards in interpretConstraints. --- .../src/domain/ConstraintInterpreter.ts | 20 ++++++++++++++----- resources/ext.neowiki/src/domain/Value.ts | 4 +++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts b/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts index 94a6dd8e..b1d5f13e 100644 --- a/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts +++ b/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts @@ -19,7 +19,9 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali return isValueEmpty( value ) ? [ emit( 'required', constraint ) ] : []; case 'minLength': { - if ( value?.type !== ValueType.String ) return []; + if ( value?.type !== ValueType.String ) { + return []; + } const out: ValueValidationError[] = []; for ( const part of ( value as StringValue ).parts ) { if ( part.trim().length < constraint.value ) { @@ -30,7 +32,9 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali } case 'maxLength': { - if ( value?.type !== ValueType.String ) return []; + if ( value?.type !== ValueType.String ) { + return []; + } const out: ValueValidationError[] = []; for ( const part of ( value as StringValue ).parts ) { if ( part.trim().length > constraint.value ) { @@ -41,19 +45,25 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali } case 'uniqueItems': { - if ( value?.type !== ValueType.String ) return []; + if ( value?.type !== ValueType.String ) { + return []; + } const parts = ( value as StringValue ).parts; return new Set( parts ).size !== parts.length ? [ emit( 'unique', constraint ) ] : []; } case 'cardinality': { - if ( value?.type !== ValueType.String ) return []; + if ( value?.type !== ValueType.String ) { + return []; + } const parts = ( value as StringValue ).parts; return parts.length > constraint.maxItems ? [ emit( 'single-value-only', constraint ) ] : []; } case 'enum': { - if ( value?.type !== ValueType.String ) return []; + if ( value?.type !== ValueType.String ) { + return []; + } const allowed = new Set( constraint.allowedValues ); const out: ValueValidationError[] = []; for ( const part of ( value as StringValue ).parts ) { diff --git a/resources/ext.neowiki/src/domain/Value.ts b/resources/ext.neowiki/src/domain/Value.ts index 866806cc..131609c9 100644 --- a/resources/ext.neowiki/src/domain/Value.ts +++ b/resources/ext.neowiki/src/domain/Value.ts @@ -87,7 +87,9 @@ export function newRelation( id: string | undefined, target: SubjectId | string } export function isValueEmpty( value: Value | undefined ): boolean { - if ( value === undefined ) return true; + if ( value === undefined ) { + return true; + } switch ( value.type ) { case ValueType.String: return value.parts.length === 0; From 8b633dd17944c6a37384351d62199ff096660dfe Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:24:20 -0400 Subject: [PATCH 09/19] Migrate TextType to declarative constraints --- .../src/domain/propertyTypes/Text.ts | 47 ++++++------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/resources/ext.neowiki/src/domain/propertyTypes/Text.ts b/resources/ext.neowiki/src/domain/propertyTypes/Text.ts index c4ded157..c9a0e56f 100644 --- a/resources/ext.neowiki/src/domain/propertyTypes/Text.ts +++ b/resources/ext.neowiki/src/domain/propertyTypes/Text.ts @@ -1,7 +1,8 @@ import type { MultiStringProperty, PropertyDefinition } from '@/domain/PropertyDefinition'; import { PropertyName } from '@/domain/PropertyDefinition'; import { newStringValue, type StringValue, ValueType } from '@/domain/Value'; -import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType'; +import { BasePropertyType } from '@/domain/PropertyType'; +import type { Constraint } from '@/domain/Constraint'; export interface TextProperty extends MultiStringProperty { @@ -34,40 +35,22 @@ export class TextType extends BasePropertyType { } as TextProperty; } - public validate( value: StringValue | undefined, property: TextProperty ): ValueValidationError[] { - const errors: ValueValidationError[] = []; - value = value === undefined ? newStringValue() : value; - - if ( property.required && value.parts.length === 0 ) { - errors.push( { code: 'required' } ); - return errors; + public getConstraints( property: TextProperty ): Constraint[] { + const constraints: Constraint[] = []; + if ( property.required ) { + constraints.push( { kind: 'required' } ); } - - // TODO: check property.multiple - - for ( const part of value.parts ) { - if ( property.minLength !== undefined && part.trim().length < property.minLength ) { - errors.push( { - code: 'min-length', - args: [ property.minLength ], - source: part, - } ); - } - - if ( property.maxLength !== undefined && part.trim().length > property.maxLength ) { - errors.push( { - code: 'max-length', - args: [ property.maxLength ], - source: part, - } ); - } + if ( property.minLength !== undefined ) { + constraints.push( { kind: 'minLength', value: property.minLength } ); } - - if ( property.uniqueItems && new Set( value.parts ).size !== value.parts.length ) { - errors.push( { code: 'unique' } ); // TODO: add source + if ( property.maxLength !== undefined ) { + constraints.push( { kind: 'maxLength', value: property.maxLength } ); } - - return errors; + if ( property.uniqueItems ) { + constraints.push( { kind: 'uniqueItems' } ); + } + // TODO: check property.multiple + return constraints; } } From 290d29bdb3e450a8fcc526ca7bd48419b6cda68e Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:26:28 -0400 Subject: [PATCH 10/19] Migrate SelectType to declarative constraints --- .../src/domain/propertyTypes/Select.ts | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/resources/ext.neowiki/src/domain/propertyTypes/Select.ts b/resources/ext.neowiki/src/domain/propertyTypes/Select.ts index 5e7a1cbe..6dad62a8 100644 --- a/resources/ext.neowiki/src/domain/propertyTypes/Select.ts +++ b/resources/ext.neowiki/src/domain/propertyTypes/Select.ts @@ -1,7 +1,8 @@ +import type { Constraint } from '@/domain/Constraint'; import type { PropertyDefinition } from '@/domain/PropertyDefinition'; import { PropertyName } from '@/domain/PropertyDefinition'; import { newStringValue, type StringValue, ValueType } from '@/domain/Value'; -import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType'; +import { BasePropertyType } from '@/domain/PropertyType'; export interface SelectOption { @@ -43,32 +44,19 @@ export class SelectType extends BasePropertyType { } as SelectProperty; } - public validate( value: StringValue | undefined, property: SelectProperty ): ValueValidationError[] { - const errors: ValueValidationError[] = []; - value = value === undefined ? newStringValue() : value; - - if ( property.required && value.parts.length === 0 ) { - errors.push( { code: 'required' } ); - return errors; - } - - const validIds = new Set( property.options.map( ( option ) => option.id ) ); - - for ( const part of value.parts ) { - if ( !validIds.has( part ) ) { - errors.push( { - code: 'invalid-option', - args: [ part ], - source: part, - } ); - } + public getConstraints( property: SelectProperty ): Constraint[] { + const constraints: Constraint[] = []; + if ( property.required ) { + constraints.push( { kind: 'required' } ); } - - if ( !property.multiple && value.parts.length > 1 ) { - errors.push( { code: 'single-value-only' } ); + constraints.push( { + kind: 'enum', + allowedValues: property.options.map( ( option ) => option.id ), + } ); + if ( !property.multiple ) { + constraints.push( { kind: 'cardinality', maxItems: 1 } ); } - - return errors; + return constraints; } } From 6aeaaa68f8b5088cb48e69edb41da32acf7050ab Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:28:04 -0400 Subject: [PATCH 11/19] Migrate NumberType to declarative constraints + validateValue --- .../src/domain/propertyTypes/Number.ts | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/resources/ext.neowiki/src/domain/propertyTypes/Number.ts b/resources/ext.neowiki/src/domain/propertyTypes/Number.ts index 26036563..2427c64a 100644 --- a/resources/ext.neowiki/src/domain/propertyTypes/Number.ts +++ b/resources/ext.neowiki/src/domain/propertyTypes/Number.ts @@ -1,3 +1,4 @@ +import type { Constraint } from '@/domain/Constraint'; import type { PropertyDefinition } from '@/domain/PropertyDefinition'; import { PropertyName } from '@/domain/PropertyDefinition'; import { newNumberValue, type NumberValue, ValueType } from '@/domain/Value'; @@ -34,29 +35,21 @@ export class NumberType extends BasePropertyType { } as NumberProperty; } - public validate( value: NumberValue | undefined, property: NumberProperty ): ValueValidationError[] { - const errors: ValueValidationError[] = []; + public getConstraints( property: NumberProperty ): Constraint[] { + return property.required ? [ { kind: 'required' } ] : []; + } - if ( property.required && value === undefined ) { - errors.push( { code: 'required' } ); - return errors; + public validateValue( value: NumberValue | undefined, property: NumberProperty ): ValueValidationError[] { + if ( value === undefined ) { + return []; } - - if ( value !== undefined ) { - if ( property.minimum !== undefined && value.number < property.minimum ) { - errors.push( { - code: 'min-value', - args: [ property.minimum ], - } ); - } - if ( property.maximum !== undefined && value.number > property.maximum ) { - errors.push( { - code: 'max-value', - args: [ property.maximum ], - } ); - } + const errors: ValueValidationError[] = []; + if ( property.minimum !== undefined && value.number < property.minimum ) { + errors.push( { code: 'min-value', args: [ property.minimum ] } ); + } + if ( property.maximum !== undefined && value.number > property.maximum ) { + errors.push( { code: 'max-value', args: [ property.maximum ] } ); } - return errors; } From a44381c95e86923820d5e7b8da19f860628f3b52 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:29:50 -0400 Subject: [PATCH 12/19] Migrate UrlType to declarative constraints + validateValue --- .../src/domain/propertyTypes/Url.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/resources/ext.neowiki/src/domain/propertyTypes/Url.ts b/resources/ext.neowiki/src/domain/propertyTypes/Url.ts index f962a333..f2af2e4e 100644 --- a/resources/ext.neowiki/src/domain/propertyTypes/Url.ts +++ b/resources/ext.neowiki/src/domain/propertyTypes/Url.ts @@ -1,6 +1,7 @@ import { MultiStringProperty, PropertyDefinition, PropertyName } from '@/domain/PropertyDefinition'; import { newStringValue, type StringValue, ValueType } from '@/domain/Value'; import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType'; +import type { Constraint } from '@/domain/Constraint'; export interface UrlProperty extends MultiStringProperty { @@ -30,29 +31,29 @@ export class UrlType extends BasePropertyType { } as UrlProperty; } - public validate( value: StringValue | undefined, property: UrlProperty ): ValueValidationError[] { - const errors: ValueValidationError[] = []; - value = value === undefined ? newStringValue() : value; - - if ( property.required && value.parts.length === 0 ) { - errors.push( { code: 'required' } ); - return errors; + public getConstraints( property: UrlProperty ): Constraint[] { + const constraints: Constraint[] = []; + if ( property.required ) { + constraints.push( { kind: 'required' } ); } - // TODO: check property.multiple + if ( property.uniqueItems ) { + constraints.push( { kind: 'uniqueItems' } ); + } + return constraints; + } + public validateValue( value: StringValue | undefined ): ValueValidationError[] { + if ( value === undefined ) { + return []; + } + const errors: ValueValidationError[] = []; for ( const part of value.parts ) { const url = part.trim(); - if ( url !== '' && !isValidUrl( url ) ) { errors.push( { code: 'invalid-url', source: part } ); } } - - if ( property.uniqueItems && new Set( value.parts ).size !== value.parts.length ) { - errors.push( { code: 'unique' } ); // TODO: add source - } - return errors; } From 9d04c27e0c020eb0a5e85ad85bccd966db649594 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:31:31 -0400 Subject: [PATCH 13/19] Migrate RelationType to declarative constraints + validateValue --- .../src/domain/propertyTypes/Relation.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/resources/ext.neowiki/src/domain/propertyTypes/Relation.ts b/resources/ext.neowiki/src/domain/propertyTypes/Relation.ts index 1462bd5b..3f5c222e 100644 --- a/resources/ext.neowiki/src/domain/propertyTypes/Relation.ts +++ b/resources/ext.neowiki/src/domain/propertyTypes/Relation.ts @@ -3,6 +3,7 @@ import { PropertyName } from '@/domain/PropertyDefinition'; import { newRelation, RelationValue, ValueType } from '@/domain/Value'; import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType'; import { SubjectId } from '@/domain/SubjectId'; +import type { Constraint } from '@/domain/Constraint'; export interface RelationProperty extends PropertyDefinition { @@ -41,25 +42,20 @@ export class RelationType extends BasePropertyType Date: Mon, 4 May 2026 14:33:32 -0400 Subject: [PATCH 14/19] Migrate DateTimeType to declarative constraints + validateValue --- .../src/domain/propertyTypes/DateTime.ts | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/resources/ext.neowiki/src/domain/propertyTypes/DateTime.ts b/resources/ext.neowiki/src/domain/propertyTypes/DateTime.ts index 3f650cfa..8f5266d7 100644 --- a/resources/ext.neowiki/src/domain/propertyTypes/DateTime.ts +++ b/resources/ext.neowiki/src/domain/propertyTypes/DateTime.ts @@ -2,6 +2,7 @@ import type { PropertyDefinition } from '@/domain/PropertyDefinition'; import { PropertyName } from '@/domain/PropertyDefinition'; import { newStringValue, type StringValue, ValueType } from '@/domain/Value'; import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType'; +import type { Constraint } from '@/domain/Constraint'; export interface DateTimeProperty extends PropertyDefinition { @@ -112,41 +113,31 @@ export class DateTimeType extends BasePropertyType 0 ) { - const timestamp = parseStrictDateTime( value.parts[ 0 ] ); - - if ( timestamp === null ) { - errors.push( { code: 'invalid-datetime' } ); - return errors; - } - - const minimum = property.minimum; - const minimumTimestamp = minimum !== undefined ? parseStrictDateTime( minimum ) : null; - if ( minimum !== undefined && minimumTimestamp !== null && timestamp < minimumTimestamp ) { - errors.push( { - code: 'min-value', - args: [ minimum ], - } ); - } - - const maximum = property.maximum; - const maximumTimestamp = maximum !== undefined ? parseStrictDateTime( maximum ) : null; - if ( maximum !== undefined && maximumTimestamp !== null && timestamp > maximumTimestamp ) { - errors.push( { - code: 'max-value', - args: [ maximum ], - } ); - } + const min = property.minimum !== undefined ? parseStrictDateTime( property.minimum ) : null; + if ( min !== null && timestamp < min ) { + errors.push( { code: 'min-value', args: [ property.minimum ] } ); + } + const max = property.maximum !== undefined ? parseStrictDateTime( property.maximum ) : null; + if ( max !== null && timestamp > max ) { + errors.push( { code: 'max-value', args: [ property.maximum ] } ); } - return errors; } From 947f22df6d8b2629e41f26c51863b8663f91fcbc Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:34:35 -0400 Subject: [PATCH 15/19] Make BasePropertyType.getConstraints abstract All six Property Type subclasses now implement getConstraints. Lifting the method from non-abstract-with-default to abstract removes the dead fallback path and makes the contract explicit. --- resources/ext.neowiki/src/domain/PropertyType.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/resources/ext.neowiki/src/domain/PropertyType.ts b/resources/ext.neowiki/src/domain/PropertyType.ts index 1dc2f761..ae964082 100644 --- a/resources/ext.neowiki/src/domain/PropertyType.ts +++ b/resources/ext.neowiki/src/domain/PropertyType.ts @@ -31,9 +31,7 @@ export abstract class BasePropertyType

Date: Mon, 4 May 2026 14:38:41 -0400 Subject: [PATCH 16/19] Reshape SubjectValidator to return SubjectValidationError list --- extension.json | 1 + i18n/en.json | 1 + i18n/qqq.json | 1 + .../src/domain/SubjectValidator.ts | 41 +++++++++---------- .../tests/domain/SubjectValidator.spec.ts | 38 +++++++++++------ 5 files changed, 48 insertions(+), 34 deletions(-) diff --git a/extension.json b/extension.json index d8d21144..60dc190b 100644 --- a/extension.json +++ b/extension.json @@ -284,6 +284,7 @@ "neowiki-property-type-datetime", "neowiki-infobox-type", "neowiki-infobox-edit-link", + "neowiki-field-label-required", "neowiki-field-required", "neowiki-field-unique", "neowiki-field-min-length", diff --git a/i18n/en.json b/i18n/en.json index 213be918..59907ed0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -50,6 +50,7 @@ "neowiki-infobox-type": "Type", "neowiki-infobox-edit-link": "Edit", + "neowiki-field-label-required": "This subject needs a label.", "neowiki-field-required": "Please provide a value.", "neowiki-field-unique": "All values must be unique.", "neowiki-field-min-length": "Minimum length is $1 characters.", diff --git a/i18n/qqq.json b/i18n/qqq.json index 63619fc3..826e649a 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -147,6 +147,7 @@ "neowiki-select-placeholder": "Placeholder text shown in the select dropdown before any option is chosen.", "neowiki-select-no-results": "Message shown in the multi-select dropdown when the typed text does not match any available option.", "neowiki-field-invalid-datetime": "Validation error shown when the entered date and time value cannot be parsed. DateTime values must be strict ISO 8601 / xsd:dateTime strings with an explicit timezone offset or 'Z' (for example, '2025-06-15T12:00:00Z' or '2025-06-15T12:00:00+02:00'). Partial values such as year-only or date-only are rejected, and min/max bounds are inclusive.", + "neowiki-field-label-required": "Error message shown when a Subject is saved or validated with an empty label.", "neowiki-property-type-datetime": "Label for the date and time property type in the schema editor", "neowiki-select-unknown-option": "Placeholder shown in a select-property display when a stored option ID cannot be resolved to a label (e.g. the option was removed from the Schema).", diff --git a/resources/ext.neowiki/src/domain/SubjectValidator.ts b/resources/ext.neowiki/src/domain/SubjectValidator.ts index 225abe5b..83dde884 100644 --- a/resources/ext.neowiki/src/domain/SubjectValidator.ts +++ b/resources/ext.neowiki/src/domain/SubjectValidator.ts @@ -1,45 +1,44 @@ -import { PropertyType, PropertyTypeRegistry } from '@/domain/PropertyType'; +import { PropertyTypeRegistry } from '@/domain/PropertyType'; +import type { ValueValidationError } from '@/domain/PropertyType'; import { Subject } from '@/domain/Subject'; import { Schema } from '@/domain/Schema'; import { Statement } from '@/domain/Statement'; +export interface SubjectValidationError { + readonly propertyName: string | null; + readonly error: ValueValidationError; +} + export class SubjectValidator { public constructor( private readonly propertyTypeRegistry: PropertyTypeRegistry, ) {} - public validate( subject: Subject, schema: Schema ): boolean { + public validate( subject: Subject, schema: Schema ): SubjectValidationError[] { + const errors: SubjectValidationError[] = []; + if ( subject.getLabel().trim() === '' ) { - return false; + errors.push( { propertyName: null, error: { code: 'label-required' } } ); } for ( const statement of subject.getStatements() ) { - if ( !this.statementIsValid( statement, schema ) ) { - return false; - } + errors.push( ...this.validateStatement( statement, schema ) ); } - return true; + return errors; } - private statementIsValid( statement: Statement, schema: Schema ): boolean { - if ( !schema.getPropertyDefinitions().has( statement.propertyName ) ) { - return true; // Statements for unknown properties are considered valid + private validateStatement( statement: Statement, schema: Schema ): SubjectValidationError[] { + const propertyDef = schema.getPropertyDefinitions().get( statement.propertyName ); + if ( propertyDef === undefined ) { + return []; } - const errors = - this.getPropertyType( statement ) - .validate( - statement.value, - schema.getPropertyDefinitions().get( statement.propertyName ), - ); - - return errors.length === 0; - } + const propertyType = this.propertyTypeRegistry.getType( statement.propertyType ); + const valueErrors = propertyType.validate( statement.value, propertyDef ); - private getPropertyType( statement: Statement ): PropertyType { - return this.propertyTypeRegistry.getType( statement.propertyType ); + return valueErrors.map( ( error ) => ( { propertyName: statement.propertyName.toString(), error } ) ); } } diff --git a/resources/ext.neowiki/tests/domain/SubjectValidator.spec.ts b/resources/ext.neowiki/tests/domain/SubjectValidator.spec.ts index f34d0d12..e6e9a1b5 100644 --- a/resources/ext.neowiki/tests/domain/SubjectValidator.spec.ts +++ b/resources/ext.neowiki/tests/domain/SubjectValidator.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { SubjectValidator } from '@/domain/SubjectValidator'; +import { SubjectValidator, SubjectValidationError } from '@/domain/SubjectValidator'; import { BasePropertyType, PropertyTypeRegistry, ValueValidationError } from '@/domain/PropertyType'; import { Subject } from '@/domain/Subject'; import { Schema } from '@/domain/Schema'; @@ -8,6 +8,7 @@ import { Statement } from '@/domain/Statement'; import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList'; import { PropertyDefinition, PropertyName } from '@/domain/PropertyDefinition'; import { newStringValue, Value, ValueType } from '@/domain/Value'; +import { Constraint } from '@/domain/Constraint'; import { newSubject } from '@/TestHelpers'; describe( 'SubjectValidator', () => { @@ -38,6 +39,10 @@ describe( 'SubjectValidator', () => { throw new Error( 'Not implemented' ); } + public getConstraints(): Constraint[] { + return []; + } + public validate(): ValueValidationError[] { return this.shouldBeValid ? [] : [ { code: 'mock-error' } ]; } @@ -78,16 +83,16 @@ describe( 'SubjectValidator', () => { } describe( 'validate', () => { - it( 'returns true when subject has no statements', () => { + it( 'returns no errors when subject has no statements', () => { const validator = new SubjectValidator( new PropertyTypeRegistry() ); const subject = newSubject(); const schema = newSchema( [] ); - expect( validator.validate( subject, schema ) ).toBe( true ); + expect( validator.validate( subject, schema ) ).toEqual( [] ); } ); - it( 'returns true when statements are for unknown properties', () => { + it( 'returns no errors when statements are for unknown properties', () => { const validator = new SubjectValidator( getFormatRegistryWithMockPropertyType( true ), ); @@ -95,10 +100,10 @@ describe( 'SubjectValidator', () => { const subject = newValidSubjectWithProperty(); const schema = newSchema( [] ); // Property not defined in schema - expect( validator.validate( subject, schema ) ).toBe( true ); + expect( validator.validate( subject, schema ) ).toEqual( [] ); } ); - it( 'returns true when all statements are valid according to their property types', () => { + it( 'returns no errors when all statements are valid according to their property types', () => { const validator = new SubjectValidator( getFormatRegistryWithMockPropertyType( true ), ); @@ -106,10 +111,10 @@ describe( 'SubjectValidator', () => { const subject = newValidSubjectWithProperty(); const schema = newSchema( [ exampleProperty ] ); - expect( validator.validate( subject, schema ) ).toBe( true ); + expect( validator.validate( subject, schema ) ).toEqual( [] ); } ); - it( 'returns false when a statement is invalid according to its property type', () => { + it( 'returns a statement error when a statement is invalid according to its property type', () => { const validator = new SubjectValidator( getFormatRegistryWithMockPropertyType( false ), ); @@ -117,23 +122,30 @@ describe( 'SubjectValidator', () => { const subject = newValidSubjectWithProperty(); const schema = newSchema( [ exampleProperty ] ); - expect( validator.validate( subject, schema ) ).toBe( false ); + expect( validator.validate( subject, schema ) ).toEqual( [ + { propertyName: exampleProperty, error: { code: 'mock-error' } }, + ] ); } ); - it( 'returns false when subject label is empty', () => { + it( 'returns a label-required error when subject label is empty', () => { const validator = new SubjectValidator( new PropertyTypeRegistry() ); const subject = newSubject( { label: '' } ); - expect( validator.validate( subject, newSchema( [] ) ) ).toBe( false ); + const expected: SubjectValidationError[] = [ + { propertyName: null, error: { code: 'label-required' } }, + ]; + expect( validator.validate( subject, newSchema( [] ) ) ).toEqual( expected ); } ); - it( 'returns false when subject label contains only whitespace', () => { + it( 'returns a label-required error when subject label contains only whitespace', () => { const validator = new SubjectValidator( new PropertyTypeRegistry() ); const subject = newSubject( { label: ' ' } ); - expect( validator.validate( subject, newSchema( [] ) ) ).toBe( false ); + expect( validator.validate( subject, newSchema( [] ) ) ).toEqual( [ + { propertyName: null, error: { code: 'label-required' } }, + ] ); } ); } ); From 53db6e97e0f7d724698300dc74d1b9e07f5464d1 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:42:58 -0400 Subject: [PATCH 17/19] Fix tsc strict-build errors after getConstraints became abstract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PropertyTypeAdapter wraps external PropertyTypeRegistration plug-ins and overrides validate directly, so getConstraints is never invoked through it — but the abstract method still must be implemented to satisfy the TS contract. Returning [] is correct. The ConstraintInterpreter test that constructed a hand-rolled StringValue used a string literal type that didn't match the ValueType enum; switch to the enum value with an explicit StringValue type annotation. --- .../ext.neowiki/src/presentation/PropertyTypeAdapter.ts | 5 +++++ .../ext.neowiki/tests/domain/ConstraintInterpreter.spec.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts b/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts index b2f7a928..6330494d 100644 --- a/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts +++ b/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts @@ -1,5 +1,6 @@ import { BasePropertyType } from '@/domain/PropertyType'; import type { ValueValidationError } from '@/domain/PropertyType'; +import type { Constraint } from '@/domain/Constraint'; import type { PropertyDefinition } from '@/domain/PropertyDefinition'; import type { Value, ValueType } from '@/domain/Value'; import type { PropertyTypeRegistration } from '@/domain/PropertyTypeRegistration'; @@ -40,6 +41,10 @@ export class PropertyTypeAdapter extends BasePropertyType { @@ -52,7 +52,7 @@ describe( 'interpretConstraints', () => { it( 'measures trimmed length', () => { // Note: newStringValue trims at construction; this confirms the interpreter ALSO trims. // Construct a StringValue with an untrimmed part by hand to bypass newStringValue's trim. - const value = { type: 'string' as const, parts: [ ' ab ' ] }; + const value: StringValue = { type: ValueType.String, parts: [ ' ab ' ] }; expect( interpretConstraints( [ { kind: 'minLength', value: 3 } ], value, From 11643424a07a67669c3a63b31871db80dda099f4 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:49:16 -0400 Subject: [PATCH 18/19] Address final review: export Constraint, document adapter, regression test - Export Constraint and ConstraintInterpreter from public-api.ts so external TS extensions subclassing BasePropertyType can satisfy the abstract getConstraints contract. - Comment PropertyTypeAdapter.getConstraints to flag that it exists only to satisfy the abstract contract since validate() is overridden. - Add DateTime regression test for required + empty StringValue. The refactor unified empty-value handling via isValueEmpty (now matching Text and Select behavior); the old DateTime.validate only flagged required when value was undefined, missing the empty-StringValue case. New test pins the unified behavior. --- .../ext.neowiki/src/presentation/PropertyTypeAdapter.ts | 2 ++ resources/ext.neowiki/src/public-api.ts | 2 ++ .../ext.neowiki/tests/domain/propertyTypes/DateTime.spec.ts | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts b/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts index 6330494d..ea43dda4 100644 --- a/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts +++ b/resources/ext.neowiki/src/presentation/PropertyTypeAdapter.ts @@ -41,6 +41,8 @@ export class PropertyTypeAdapter extends BasePropertyType { expect( dateTimeType.validate( undefined, property ) ).toEqual( [ { code: 'required' } ] ); } ); + it( 'returns required error for required empty StringValue', () => { + const property = newDateTimeProperty( { required: true } ); + + expect( dateTimeType.validate( newStringValue(), property ) ).toEqual( [ { code: 'required' } ] ); + } ); + it( 'returns no errors for valid datetime within bounds', () => { const property = newDateTimeProperty( { minimum: '2020-01-01T00:00:00Z', From f131b7f05ac8a0f07e3742dcf34a9a02f779489d Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Mon, 4 May 2026 14:58:23 -0400 Subject: [PATCH 19/19] Drop unnecessary StringValue casts and document cardinality code TypeScript narrowing handles the discriminated-union refinement through the ValueType.String guard, so the (value as StringValue) casts were defensive overkill. Remove them. Add a comment near the cardinality emit explaining that the 'single-value-only' code is hardcoded for maxItems === 1 (today's only use case via Select). Constraint is now exported, so external plug-ins could construct cardinality records with maxItems > 1; the comment flags where to branch the error code when that lands. --- .../src/domain/ConstraintInterpreter.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts b/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts index b1d5f13e..fced5e8a 100644 --- a/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts +++ b/resources/ext.neowiki/src/domain/ConstraintInterpreter.ts @@ -1,5 +1,5 @@ import type { Constraint } from '@/domain/Constraint'; -import { isValueEmpty, type StringValue, type Value, ValueType } from '@/domain/Value'; +import { isValueEmpty, type Value, ValueType } from '@/domain/Value'; import type { ValueValidationError } from '@/domain/PropertyType'; export function interpretConstraints( @@ -23,7 +23,7 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali return []; } const out: ValueValidationError[] = []; - for ( const part of ( value as StringValue ).parts ) { + for ( const part of value.parts ) { if ( part.trim().length < constraint.value ) { out.push( emit( 'min-length', constraint, { args: [ constraint.value ], source: part } ) ); } @@ -36,7 +36,7 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali return []; } const out: ValueValidationError[] = []; - for ( const part of ( value as StringValue ).parts ) { + for ( const part of value.parts ) { if ( part.trim().length > constraint.value ) { out.push( emit( 'max-length', constraint, { args: [ constraint.value ], source: part } ) ); } @@ -48,7 +48,7 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali if ( value?.type !== ValueType.String ) { return []; } - const parts = ( value as StringValue ).parts; + const parts = value.parts; return new Set( parts ).size !== parts.length ? [ emit( 'unique', constraint ) ] : []; } @@ -56,7 +56,10 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali if ( value?.type !== ValueType.String ) { return []; } - const parts = ( value as StringValue ).parts; + const parts = value.parts; + // The 'single-value-only' code assumes maxItems === 1 (the only value used today). + // When a maxItems > 1 use case lands, branch the error code (e.g. 'max-items' with + // args: [ maxItems ]) and add a matching i18n message. return parts.length > constraint.maxItems ? [ emit( 'single-value-only', constraint ) ] : []; } @@ -66,7 +69,7 @@ function evaluate( constraint: Constraint, value: Value | undefined ): ValueVali } const allowed = new Set( constraint.allowedValues ); const out: ValueValidationError[] = []; - for ( const part of ( value as StringValue ).parts ) { + for ( const part of value.parts ) { if ( !allowed.has( part ) ) { out.push( emit( 'invalid-option', constraint, { args: [ part ], source: part } ) ); }