diff --git a/packages/server/src/models/statement/PositionRules.test.ts b/packages/server/src/models/statement/PositionRules.test.ts new file mode 100644 index 000000000..ae5153469 --- /dev/null +++ b/packages/server/src/models/statement/PositionRules.test.ts @@ -0,0 +1,84 @@ +import { EntityEnums } from "@shared/enums"; +import "ts-jest"; +import { PositionRules } from "./PositionRules"; + +describe("models/statement/PositionRules", function () { + test("test PositionRules.intersectRules", function () { + // undefined rules - without intersection + expect(PositionRules.hasIntersection([undefined, undefined])).toEqual( + false + ); + + // no actions - should return true + expect(PositionRules.hasIntersection([])).toEqual(true); + + // single action with single rule -> true + expect( + PositionRules.hasIntersection([[EntityEnums.Extension.Empty]]) + ).toEqual(true); + + // two actions both with empty + expect( + PositionRules.hasIntersection([ + [EntityEnums.Extension.Empty], + [EntityEnums.Extension.Empty], + ]) + ).toEqual(true); + + // two actions both with empty intersected + expect( + PositionRules.hasIntersection([ + [EntityEnums.Extension.Empty], + [EntityEnums.Extension.Empty, EntityEnums.Class.Statement], + ]) + ).toEqual(true); + + expect( + PositionRules.hasIntersection([ + [EntityEnums.Class.Concept], + [EntityEnums.Class.Concept], + ]) + ).toEqual(true); + + expect( + PositionRules.hasIntersection([ + [EntityEnums.Class.Concept, EntityEnums.Class.Action], + [EntityEnums.Class.Concept, EntityEnums.Class.Action], + ]) + ).toEqual(true); + + expect( + PositionRules.hasIntersection([ + [EntityEnums.Class.Resource, EntityEnums.Class.Action], + [EntityEnums.Class.Location, EntityEnums.Class.Concept], + ]) + ).toEqual(false); + + expect( + PositionRules.hasIntersection([ + [EntityEnums.Extension.Empty], + [EntityEnums.Class.Action], + [EntityEnums.Class.Location], + [EntityEnums.Class.Concept], + [EntityEnums.Class.Concept], + ]) + ).toEqual(false); + }); + + test("test PositionRules.allowsOnlyEmpty", function () { + expect(PositionRules.allowsOnlyEmpty([])).toEqual(false); + expect(PositionRules.allowsOnlyEmpty(undefined)).toEqual(false); + expect(PositionRules.allowsOnlyEmpty([EntityEnums.Class.Concept])).toEqual( + false + ); + expect( + PositionRules.allowsOnlyEmpty([ + EntityEnums.Class.Concept, + EntityEnums.Extension.Empty, + ]) + ).toEqual(false); + expect( + PositionRules.allowsOnlyEmpty([EntityEnums.Extension.Empty]) + ).toEqual(true); + }); +}); diff --git a/packages/server/src/models/statement/PositionRules.ts b/packages/server/src/models/statement/PositionRules.ts index 278657f65..dad6291fa 100644 --- a/packages/server/src/models/statement/PositionRules.ts +++ b/packages/server/src/models/statement/PositionRules.ts @@ -5,39 +5,70 @@ import { IAction } from "@shared/types"; export class PositionRules { classes: EntityEnums.ExtendedClass[] = []; undefinedActions: string[] = []; - allEmpty = true; + allUndefined = true; mismatch = false; constructor(actions: IAction[], position: EntityEnums.Position) { for (const action of actions) { const rules = ActionEntity.toRules(action.data.entities)[position]; - if (!rules) { + const undefinedRules = !rules || !rules.length; + if (undefinedRules) { this.undefinedActions.push(action.id); } - this.allEmpty = this.allEmpty && PositionRules.isRuleEmpty(rules); - - if (!this.mismatch) { - if (this.classes.length) { - this.mismatch = true; - for (const rule of rules || []) { - if (this.classes.indexOf(rule) !== -1) { - this.mismatch = false; - break; - } - } - } - } + + this.allUndefined = this.allUndefined && undefinedRules; + this.classes = this.classes.concat(rules || []); } + + this.mismatch = !PositionRules.hasIntersection( + actions.map((a) => ActionEntity.toRules(a.data.entities)[position]) + ); + } + + /** + * Predicate for testing if current rules-set allows empty actant + * @returns + */ + allowsEmpty(): boolean { + return this.classes.includes(EntityEnums.Extension.Empty); } - static isRuleEmpty(rules: EntityEnums.ExtendedClass[] | undefined): boolean { - if (!rules) { - return false; + /** + * Tests if arrays of allowed classes across actions has any intersection + * @param rules + * @returns + */ + static hasIntersection( + rules: (EntityEnums.ExtendedClass[] | undefined)[] + ): boolean { + if (!Array.isArray(rules) || rules.length < 2) { + return true; } + let intersection = rules[0] || []; + + for (let i = 1; i < rules.length; i++) { + const currentArray = rules[i] || []; + + intersection = (intersection || []).filter((element) => + currentArray.includes(element) + ); + } + + return intersection.length > 0; + } + + /** + * Tests if some rule disallows any actant + * @param rules + * @returns + */ + static allowsOnlyEmpty( + rules: EntityEnums.ExtendedClass[] | undefined + ): boolean { return ( - !rules.length || !!rules.find((r) => r === EntityEnums.Extension.Empty) + !!rules && rules.length === 1 && rules[0] === EntityEnums.Extension.Empty ); } } diff --git a/packages/server/src/models/statement/response.test.ts b/packages/server/src/models/statement/response.test.ts index c440a0f75..c8cbcfca8 100644 --- a/packages/server/src/models/statement/response.test.ts +++ b/packages/server/src/models/statement/response.test.ts @@ -17,7 +17,7 @@ class MockResponse extends ResponseStatement { } // @ts-ignore - addAction(map: { [key: EntityEnums.Position]: EntityEnums.Class[] }) { + addAction(map: { [key: EntityEnums.Position]: EntityEnums.ExtendedClass[] }) { const action = new Action({ id: `action-${this.data.actions.length + 1}`, }); @@ -163,25 +163,47 @@ describe("models/statement/response", function () { ws.find( (w) => w.type === WarningTypeEnums.WA && - w.position.entityId === location.id + w.position?.entityId === location.id ) ).toBeTruthy(); }); }); - describe("empty", () => { + describe("[empty]", () => { it("should return OK for no actant", () => { const response = MockResponse.new(); - response.addAction({ [EntityEnums.Position.Subject]: [] }); + response.addAction({ + [EntityEnums.Position.Subject]: [EntityEnums.Extension.Empty], + }); const ws = response.getWarningsForPosition( EntityEnums.Position.Subject ); expect(ws).toHaveLength(0); }); - it("should return ANA for empty rules", () => { + it("should return ANA for P actant", () => { const response = MockResponse.new(); - response.addAction({ [EntityEnums.Position.Subject]: [] }); + response.addAction({ + [EntityEnums.Position.Subject]: [EntityEnums.Extension.Empty], + }); + const act1 = response.addActant( + new Person({ id: "person" }), + EntityEnums.Position.Subject + ); + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect( + ws.filter((w) => w.type === WarningTypeEnums.ANA) + ).toHaveLength(1); + expect(ws).toHaveLength(1); + }); + + it("should return ANA for P/G actants", () => { + const response = MockResponse.new(); + response.addAction({ + [EntityEnums.Position.Subject]: [EntityEnums.Extension.Empty], + }); const act1 = response.addActant( new Person({ id: "person" }), EntityEnums.Position.Subject @@ -190,25 +212,147 @@ describe("models/statement/response", function () { new Group({ id: "group" }), EntityEnums.Position.Subject ); - const ws = response.getWarningsForPosition( EntityEnums.Position.Subject ); expect( ws.filter( (w) => - (w.type === WarningTypeEnums.ANA && - w.position.entityId === act1.id) || - w.position.entityId === act2.id + w.type === WarningTypeEnums.ANA && + w.position?.entityId === act1.id ) - ).toHaveLength(2); + ).toHaveLength(1); + expect( + ws.filter( + (w) => + w.type === WarningTypeEnums.ANA && + w.position?.entityId === act2.id + ) + ).toHaveLength(1); expect(ws).toHaveLength(2); }); }); + + describe("[empty, P]", () => { + it("should return OK for no actant", () => { + const response = MockResponse.new(); + response.addAction({ + [EntityEnums.Position.Subject]: [ + EntityEnums.Extension.Empty, + EntityEnums.Class.Person, + ], + }); + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect(ws).toHaveLength(0); + }); + + it("should return OK for P actant", () => { + const response = MockResponse.new(); + response.addAction({ + [EntityEnums.Position.Subject]: [ + EntityEnums.Extension.Empty, + EntityEnums.Class.Person, + ], + }); + const act1 = response.addActant( + new Person({ id: "person" }), + EntityEnums.Position.Subject + ); + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect(ws).toHaveLength(0); + }); + + it("should return OK for P actants", () => { + const response = MockResponse.new(); + response.addAction({ + [EntityEnums.Position.Subject]: [ + EntityEnums.Extension.Empty, + EntityEnums.Class.Person, + ], + }); + const act1 = response.addActant( + new Person({ id: "person" }), + EntityEnums.Position.Subject + ); + const act2 = response.addActant( + new Person({ id: "person2" }), + EntityEnums.Position.Subject + ); + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect(ws).toHaveLength(0); + }); + + it("should return WA for one other actant", () => { + const response = MockResponse.new(); + response.addAction({ + [EntityEnums.Position.Subject]: [ + EntityEnums.Extension.Empty, + EntityEnums.Class.Person, + ], + }); + const grp1 = response.addActant( + new Group({ id: "group" }), + EntityEnums.Position.Subject + ); + const act2 = response.addActant( + new Person({ id: "person2" }), + EntityEnums.Position.Subject + ); + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect( + ws.filter( + (w) => + w.type === WarningTypeEnums.WA && + w.position?.entityId === grp1.id + ) + ).toHaveLength(1); + expect(ws).toHaveLength(1); + }); + }); + + describe("undefined", () => { + it("should return OK for no actant", () => { + const response = MockResponse.new(); + response.addAction({ [EntityEnums.Position.Subject]: [] }); + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect(ws).toHaveLength(0); + }); + + it("should return AVU for empty rules", () => { + const response = MockResponse.new(); + response.addAction({ [EntityEnums.Position.Subject]: [] }); + const act1 = response.addActant( + new Person({ id: "person" }), + EntityEnums.Position.Subject + ); + const act2 = response.addActant( + new Group({ id: "group" }), + EntityEnums.Position.Subject + ); + + const ws = response.getWarningsForPosition( + EntityEnums.Position.Subject + ); + expect( + ws.filter((w) => w.type === WarningTypeEnums.AVU) + ).toHaveLength(1); + expect(ws).toHaveLength(1); + }); + }); }); describe("2 actions", () => { - describe("any + any", () => { + describe("[any] + [any]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ @@ -243,7 +387,7 @@ describe("models/statement/response", function () { }); }); - describe("any + P", () => { + describe("[any] + [P]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ @@ -291,7 +435,7 @@ describe("models/statement/response", function () { ws.find( (w) => w.type === WarningTypeEnums.WA && - w.position.entityId === group.id + w.position?.entityId === group.id ) ).toBeTruthy(); }); @@ -320,32 +464,28 @@ describe("models/statement/response", function () { ws.find( (w) => w.type === WarningTypeEnums.WA && - w.position.entityId === group.id + w.position?.entityId === group.id ) ).toBeTruthy(); }); }); }); - describe("any + empty", () => { + describe("[any] + [empty]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ [EntityEnums.Position.Subject]: EntityEnums.PLOGESTRB, }); response.addAction({ - [EntityEnums.Position.Subject]: [], + [EntityEnums.Position.Subject]: [EntityEnums.Extension.Empty], }); return response; }; - it("should return WAC for P", () => { + it("should return WAC for no actant", () => { const response = prepareResponse(); - response.addActant( - new Person({ id: "person1" }), - EntityEnums.Position.Subject - ); const ws = response.getWarningsForPosition( EntityEnums.Position.Subject ); @@ -353,8 +493,12 @@ describe("models/statement/response", function () { expect(ws.find((w) => w.type === WarningTypeEnums.WAC)).toBeTruthy(); }); - it("should return WAC for no actant", () => { + it("should return WAC for P", () => { const response = prepareResponse(); + response.addActant( + new Person({ id: "person1" }), + EntityEnums.Position.Subject + ); const ws = response.getWarningsForPosition( EntityEnums.Position.Subject ); @@ -380,7 +524,7 @@ describe("models/statement/response", function () { }); }); - describe("P + [A,G]", () => { + describe("[P] + [A,G]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ @@ -431,7 +575,7 @@ describe("models/statement/response", function () { }); }); - describe("P + [P,G]", () => { + describe("[P] + [P,G]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ @@ -500,13 +644,15 @@ describe("models/statement/response", function () { }); }); - describe("P + undefined", () => { + describe("[P] + [undefined]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ [EntityEnums.Position.Subject]: [EntityEnums.Class.Person], }); - response.addAction({}); + response.addAction({ + [EntityEnums.Position.Subject]: [], + }); return response; }; @@ -553,11 +699,11 @@ describe("models/statement/response", function () { }); }); - describe("empty + empty", () => { + describe("[empty] + [empty]", () => { const prepareResponse = () => { const response = MockResponse.new(); response.addAction({ - [EntityEnums.Position.Subject]: [], + [EntityEnums.Position.Subject]: [EntityEnums.Extension.Empty], }); response.addAction({ [EntityEnums.Position.Subject]: [EntityEnums.Extension.Empty], diff --git a/packages/server/src/models/statement/response.ts b/packages/server/src/models/statement/response.ts index a456d8517..dc972dc45 100644 --- a/packages/server/src/models/statement/response.ts +++ b/packages/server/src/models/statement/response.ts @@ -158,33 +158,36 @@ export class ResponseStatement extends Statement implements IResponseStatement { position ); - rules.undefinedActions.forEach((actionId) => { - warnings.push( - this.newStatementWarning(WarningTypeEnums.AVU, { - section: position, - entityId: actionId, - }) - ); - }); - - if (!actants.length && !rules.allEmpty) { + if (rules.mismatch) { warnings.push( - this.newStatementWarning(WarningTypeEnums.MA, { + this.newStatementWarning(WarningTypeEnums.WAC, { section: `${position}`, }) ); } - if (rules.mismatch) { - const MAindex = warnings.findIndex((w) => w.type === WarningTypeEnums.MA); - if (MAindex !== -1) { - warnings.splice(MAindex, 1); + if (!rules.mismatch && !actants.length) { + if (!rules.allowsEmpty() && !rules.allUndefined) { + warnings.push( + this.newStatementWarning(WarningTypeEnums.MA, { + section: `${position}`, + }) + ); + } else if (rules.allUndefined) { + return warnings; } + } + + rules.undefinedActions.forEach((actionId) => { warnings.push( - this.newStatementWarning(WarningTypeEnums.WAC, { - section: `${position}`, + this.newStatementWarning(WarningTypeEnums.AVU, { + section: position, + entityId: actionId, }) ); + }); + + if (rules.allUndefined || rules.mismatch) { return warnings; } @@ -199,7 +202,7 @@ export class ResponseStatement extends Statement implements IResponseStatement { if (!actionRules) { // action rules undefined for this position - only common warning should be returned (AVU) - } else if (PositionRules.isRuleEmpty(actionRules)) { + } else if (PositionRules.allowsOnlyEmpty(actionRules)) { warnings.push( this.newStatementWarning(WarningTypeEnums.ANA, { section: `${position}`, diff --git a/packages/shared/enums/entities.ts b/packages/shared/enums/entities.ts index a13a2872f..62cf2948d 100644 --- a/packages/shared/enums/entities.ts +++ b/packages/shared/enums/entities.ts @@ -34,6 +34,14 @@ export namespace EntityEnums { Event = "E", } + export enum Extension { + Any = "*", + Empty = "empty", + NoClass = "X", + Invalid = "?", + } + + export const PLOGESTR = [ Class.Person, Class.Location, @@ -55,6 +63,22 @@ export namespace EntityEnums { Class.Resource, Class.Being, ]; + export const ExtendedClasses = [ + Class.Action, + Class.Territory, + Class.Statement, + Class.Resource, + Class.Person, + Class.Being, + Class.Group, + Class.Object, + Class.Concept, + Class.Location, + Class.Value, + Class.Event, + Extension.Empty, + ]; + export const LOESBV = [ Class.Location, Class.Object, @@ -64,12 +88,6 @@ export namespace EntityEnums { Class.Value, ]; - export enum Extension { - Any = "*", - Empty = "empty", - NoClass = "X", - Invalid = "?", - } export type ExtendedClass = Class | Extension;