F: Improve frontend and backend checks for graph editing

This commit is contained in:
Ivan 2025-11-19 13:29:59 +03:00
parent 584f62579d
commit e4b60f47da
12 changed files with 91 additions and 23 deletions

View File

@ -56,6 +56,7 @@ class TestChangeConstituents(EndpointTester):
self.operation3.refresh_from_db() self.operation3.refresh_from_db()
self.ks3 = RSForm(self.operation3.result) self.ks3 = RSForm(self.operation3.result)
self.assertEqual(self.ks3.constituentsQ().count(), 4) self.assertEqual(self.ks3.constituentsQ().count(), 4)
self.ks3X1 = Constituenta.objects.get(as_child__parent_id=self.ks1X1.pk)
self.layout_data = [ self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}, {'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
@ -142,6 +143,29 @@ class TestChangeConstituents(EndpointTester):
self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}') self.assertEqual(inherited_cst.definition_raw, r'@{X2|sing,datv}')
@decl_endpoint('/api/rsforms/{item}/update-cst', method='patch')
def test_update_constituenta_inherited(self):
data = {
'target': self.ks3X1.pk,
'item_data': {
'definition_formal': r'123',
}
}
self.executeBadData(data, item=self.ks3.model.pk)
data = {
'target': self.ks3X1.pk,
'item_data': {
'term_raw': r'42',
'convention': r'1337',
}
}
self.executeOK(data, item=self.ks3.model.pk)
self.ks3X1.refresh_from_db()
self.assertEqual(self.ks3X1.term_raw, data['item_data']['term_raw'])
self.assertEqual(self.ks3X1.convention, data['item_data']['convention'])
@decl_endpoint('/api/rsforms/{item}/delete-multiple-cst', method='patch') @decl_endpoint('/api/rsforms/{item}/delete-multiple-cst', method='patch')
def test_delete_constituenta(self): def test_delete_constituenta(self):
data = {'items': [self.ks2X1.pk]} data = {'items': [self.ks2X1.pk]}

View File

@ -116,6 +116,11 @@ class CstUpdateSerializer(StrictSerializer):
raise serializers.ValidationError({ raise serializers.ValidationError({
'alias': msg.aliasTaken(new_alias) 'alias': msg.aliasTaken(new_alias)
}) })
if 'definition_formal' in attrs['item_data']:
if Inheritance.objects.filter(child=cst).exists():
raise serializers.ValidationError({
'definition_formal': msg.changeInheritedDefinition()
})
return attrs return attrs

View File

@ -148,7 +148,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@extend_schema( @extend_schema(
summary='update crucial attributes of a given list of constituents', summary='update crucial attribute of a given list of constituents',
tags=['RSForm'], tags=['RSForm'],
request=s.CrucialUpdateSerializer, request=s.CrucialUpdateSerializer,
responses={ responses={
@ -160,7 +160,7 @@ class RSFormViewSet(viewsets.GenericViewSet, generics.ListAPIView, generics.Retr
) )
@action(detail=True, methods=['patch'], url_path='update-crucial') @action(detail=True, methods=['patch'], url_path='update-crucial')
def update_crucial(self, request: Request, pk) -> HttpResponse: def update_crucial(self, request: Request, pk) -> HttpResponse:
''' Update crucial attributes of a given list of constituents. ''' ''' Update crucial attribute of a given list of constituents. '''
item = self._get_item() item = self._get_item()
serializer = s.CrucialUpdateSerializer(data=request.data, partial=True, context={'schema': item}) serializer = s.CrucialUpdateSerializer(data=request.data, partial=True, context={'schema': item})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)

View File

@ -22,6 +22,10 @@ def constituentaNotInRSform(title: str):
return f'Конституента не принадлежит схеме: {title}' return f'Конституента не принадлежит схеме: {title}'
def changeInheritedDefinition():
return 'Нельзя изменить определение наследника'
def constituentaNotFromOperation(): def constituentaNotFromOperation():
return 'Конституента не соответствую аргументам операции' return 'Конституента не соответствую аргументам операции'

View File

@ -88,7 +88,7 @@ export class RSFormLoader {
cst.spawn = []; cst.spawn = [];
cst.attributes = []; cst.attributes = [];
cst.spawn_alias = []; cst.spawn_alias = [];
cst.parent_schema = schemaByCst.get(cst.id); cst.parent_schema = schemaByCst.get(cst.id) ?? null;
cst.parent_schema_index = cst.parent_schema ? parents.indexOf(cst.parent_schema) + 1 : 0; cst.parent_schema_index = cst.parent_schema ? parents.indexOf(cst.parent_schema) + 1 : 0;
cst.is_inherited = inherit_children.has(cst.id); cst.is_inherited = inherit_children.has(cst.id);
cst.has_inherited_children = inherit_parents.has(cst.id); cst.has_inherited_children = inherit_parents.has(cst.id);

View File

@ -19,7 +19,7 @@ export function SchemasGuide({ schema }: SchemasGuideProps) {
const aliases: string[] = []; const aliases: string[] = [];
const indexes: number[] = []; const indexes: number[] = [];
schema.items.forEach(cst => { schema.items.forEach(cst => {
if (cst.parent_schema && !processed.has(cst.parent_schema)) { if (cst.parent_schema !== null && !processed.has(cst.parent_schema)) {
const item = libraryItems.find(item => item.id === cst.parent_schema); const item = libraryItems.find(item => item.id === cst.parent_schema);
if (item) { if (item) {
aliases.push(item.alias); aliases.push(item.alias);

View File

@ -86,7 +86,7 @@ export interface IConstituenta {
*/ */
parent_schema_index: number; parent_schema_index: number;
/** {@link LibraryItem} that contains parent of this inherited {@link IConstituenta}. */ /** {@link LibraryItem} that contains parent of this inherited {@link IConstituenta}. */
parent_schema?: number; parent_schema: number | null;
/** Indicates if this {@link IConstituenta} is inherited. */ /** Indicates if this {@link IConstituenta} is inherited. */
is_inherited: boolean; is_inherited: boolean;
/** Indicates if this {@link IConstituenta} has children that are inherited. */ /** Indicates if this {@link IConstituenta} has children that are inherited. */

View File

@ -15,7 +15,7 @@ import {
import clsx from 'clsx'; import clsx from 'clsx';
import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow'; import { DiagramFlow, useReactFlow } from '@/components/flow/diagram-flow';
import { useContinuousPan } from '@/components/flow/use-continous-panning'; import { useContinuousPan } from '@/components/flow/use-continuous-panning';
import { useWindowSize } from '@/hooks/use-window-size'; import { useWindowSize } from '@/hooks/use-window-size';
import { useFitHeight, useMainHeight } from '@/stores/app-layout'; import { useFitHeight, useMainHeight } from '@/stores/app-layout';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
@ -255,20 +255,26 @@ export function TGFlow() {
const sourceID = Number(connection.source); const sourceID = Number(connection.source);
const targetID = Number(connection.target); const targetID = Number(connection.target);
if (
(connectionType === TGEdgeType.attribution && schema.attribution_graph.hasEdge(sourceID, targetID)) ||
(connectionType === TGEdgeType.definition && schema.graph.hasEdge(sourceID, targetID))
) {
toast.info(errorMsg.connectionExists);
return;
}
if (connectionType === TGEdgeType.definition) {
const sourceCst = schema.cstByID.get(sourceID); const sourceCst = schema.cstByID.get(sourceID);
const targetCst = schema.cstByID.get(targetID); const targetCst = schema.cstByID.get(targetID);
if (!targetCst || !sourceCst) { if (!targetCst || !sourceCst) {
throw new Error('Constituents not found'); throw new Error('Constituents not found');
} }
if (connectionType === TGEdgeType.definition) {
if (targetCst.is_inherited) {
toast.error(errorMsg.changeInheritedDefinition);
return;
}
if (schema.graph.hasEdge(sourceID, targetID)) {
toast.error(errorMsg.connectionExists);
return;
}
if (schema.graph.isReachable(targetID, sourceID)) {
toast.error(errorMsg.cyclingEdge);
return;
}
const newExpressions = addAliasReference(targetCst.definition_formal, sourceCst.alias); const newExpressions = addAliasReference(targetCst.definition_formal, sourceCst.alias);
void updateConstituenta({ void updateConstituenta({
itemID: schema.id, itemID: schema.id,
@ -279,8 +285,20 @@ export function TGFlow() {
} }
} }
}); });
return;
} else { } else {
if (schema.attribution_graph.hasEdge(sourceID, targetID)) {
toast.error(errorMsg.connectionExists);
return;
}
if (schema.attribution_graph.isReachable(targetID, sourceID)) {
toast.error(errorMsg.cyclingEdge);
return;
}
if (targetCst.parent_schema !== null && targetCst.parent_schema === sourceCst.parent_schema) {
toast.error(errorMsg.addInheritedEdge);
return;
}
void createAttribution({ void createAttribution({
itemID: schema.id, itemID: schema.id,
data: { data: {

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { urls, useConceptNavigation } from '@/app'; import { urls, useConceptNavigation } from '@/app';
import { useAIStore } from '@/features/ai/stores/ai-context'; import { useAIStore } from '@/features/ai/stores/ai-context';
@ -14,7 +15,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER, prefixes } from '@/utils/constants'; import { PARAMETER, prefixes } from '@/utils/constants';
import { promptText } from '@/utils/labels'; import { errorMsg, promptText } from '@/utils/labels';
import { type RO } from '@/utils/meta'; import { type RO } from '@/utils/meta';
import { promptUnsaved } from '@/utils/utils'; import { promptUnsaved } from '@/utils/utils';
@ -302,7 +303,16 @@ export const RSEditState = ({
const ids = selectedEdges[0].split('-'); const ids = selectedEdges[0].split('-');
const sourceID = Number(ids[0]); const sourceID = Number(ids[0]);
const targetID = Number(ids[1]); const targetID = Number(ids[1]);
const sourceCst = schema.cstByID.get(sourceID);
const targetCst = schema.cstByID.get(targetID);
if (!targetCst || !sourceCst) {
throw new Error('Constituents not found');
}
if (schema.attribution_graph.hasEdge(sourceID, targetID)) { if (schema.attribution_graph.hasEdge(sourceID, targetID)) {
if (targetCst.parent_schema !== null && targetCst.parent_schema === sourceCst.parent_schema) {
toast.error(errorMsg.deleteInheritedEdge);
return;
}
void deleteAttribution({ void deleteAttribution({
itemID: schema.id, itemID: schema.id,
data: { data: {
@ -311,10 +321,9 @@ export const RSEditState = ({
} }
}); });
} else if (schema.graph.hasEdge(sourceID, targetID)) { } else if (schema.graph.hasEdge(sourceID, targetID)) {
const sourceCst = schema.cstByID.get(sourceID); if (targetCst.is_inherited) {
const targetCst = schema.cstByID.get(targetID); toast.error(errorMsg.changeInheritedDefinition);
if (!targetCst || !sourceCst) { return;
throw new Error('Constituents not found');
} }
const newExpressions = removeAliasReference(targetCst.definition_formal, sourceCst.alias); const newExpressions = removeAliasReference(targetCst.definition_formal, sourceCst.alias);
void updateConstituenta({ void updateConstituenta({

View File

@ -149,6 +149,10 @@ export class Graph<NodeID = number> {
return !!sourceNode.outputs.find(id => id === destination); return !!sourceNode.outputs.find(id => id === destination);
} }
isReachable(source: NodeID, destination: NodeID): boolean {
return this.expandAllOutputs([source]).includes(destination);
}
rootNodes(): NodeID[] { rootNodes(): NodeID[] {
return [...this.nodes.keys()].filter(id => !this.nodes.get(id)?.inputs.length); return [...this.nodes.keys()].filter(id => !this.nodes.get(id)?.inputs.length);
} }

View File

@ -70,7 +70,11 @@ export const errorMsg = {
invalidLocation: 'Некорректный формат пути', invalidLocation: 'Некорректный формат пути',
emptySubstitutions: 'Выберите хотя бы одно отождествление', emptySubstitutions: 'Выберите хотя бы одно отождествление',
invalidResponse: 'Некорректный ответ сервера', invalidResponse: 'Некорректный ответ сервера',
connectionExists: 'Связь уже существует' connectionExists: 'Связь уже существует',
cyclingEdge: 'Связь образует цикл',
changeInheritedDefinition: 'Нельзя изменить определение наследника',
addInheritedEdge: 'Новая связь между наследниками из одной схемы не допускается',
deleteInheritedEdge: 'Нельзя удалить связь между наследниками из одной схемы'
} as const; } as const;
/** /**