F: Implementing references pt2
Some checks failed
Backend CI / build (3.12) (push) Has been cancelled
Frontend CI / build (22.x) (push) Has been cancelled
Backend CI / notify-failure (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-08-04 22:42:02 +03:00
parent 13e56f51ea
commit 266fdf0c30
9 changed files with 117 additions and 137 deletions

View File

@ -39,34 +39,29 @@ class OperationSchemaCached:
def delete_reference(self, target: int, keep_connections: bool = False, keep_constituents: bool = False):
''' Delete Reference Operation. '''
if not keep_connections:
self.delete_operation(target, keep_constituents)
return
self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target]
if keep_connections:
referred_operations = operation.getQ_reference_target()
if len(referred_operations) == 1:
referred_operation = referred_operations[0]
for arg in operation.getQ_as_argument():
arg.pk = None
arg.argument = referred_operation
arg.save()
else:
pass
# if target.result_id is not None:
# self.before_delete_cst(schema, schema.cache.constituents) # TODO: use operation instead of schema
reference_target = self.cache.reference_target.get(target)
if reference_target:
for arg in operation.getQ_as_argument():
arg.argument_id = reference_target
arg.save()
self.cache.remove_operation(target)
operation.delete()
def delete_operation(self, target: int, keep_constituents: bool = False):
''' Delete Operation. '''
self.cache.ensure_loaded_subs()
operation = self.cache.operation_by_id[target]
schema = self.cache.get_schema(operation)
children = self.cache.graph.outputs[target]
if schema is not None and len(children) > 0:
ids = [cst.pk for cst in schema.cache.constituents]
if operation.result is not None and len(children) > 0:
ids = list(Constituenta.objects.filter(schema=operation.result).values_list('pk', flat=True))
if not keep_constituents:
self.before_delete_cst(schema.model.pk, ids)
self._cascade_delete_inherited(operation.pk, ids)
else:
inheritance_to_delete: list[Inheritance] = []
for child_id in children:
@ -837,6 +832,8 @@ class OssCache:
del self._schema_by_id[target.result_id]
self.operations.remove(self.operation_by_id[operation])
del self.operation_by_id[operation]
if operation in self.reference_target:
del self.reference_target[operation]
if self.is_loaded_subs:
del self.substitutions[operation]
del self.inheritance[operation]

View File

@ -80,23 +80,42 @@ class ReferencePropagationTestCase(EndpointTester):
self.owned.set_arguments(self.operation5.pk, [self.operation4, self.operation3])
self.owned.set_substitutions(self.operation5.pk, [{
'original': self.ks4X1,
'substitution': self.ks1X1
'substitution': self.ks1X2
}])
self.owned.execute_operation(self.operation5)
self.operation5.refresh_from_db()
self.ks5 = RSForm(self.operation5.result)
self.ks5D4 = self.ks5.insert_last(
alias='D4',
definition_formal=r'X1 X2 X3 S1 D1 D2 D3',
definition_formal=r'X1 X2 X3 X4 S1 D1 D2 D3',
convention='KS5D4'
)
self.operation6 = self.owned.create_operation(
alias='6',
operation_type=OperationType.SYNTHESIS
)
self.owned.set_arguments(self.operation6.pk, [self.operation2, self.operation3])
self.owned.set_substitutions(self.operation6.pk, [{
'original': self.ks2X1,
'substitution': self.ks1X1
}])
self.owned.execute_operation(self.operation6)
self.operation6.refresh_from_db()
self.ks6 = RSForm(self.operation6.result)
self.ks6D2 = self.ks6.insert_last(
alias='D2',
definition_formal=r'X1 X2 X3 S1 D1',
convention='KS6D2'
)
self.layout_data = [
{'nodeID': 'o' + str(self.operation1.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation2.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation3.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation4.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40}
{'nodeID': 'o' + str(self.operation5.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
{'nodeID': 'o' + str(self.operation6.pk), 'x': 0, 'y': 0, 'width': 150, 'height': 40},
]
layout = OperationSchema.layoutQ(self.owned_id)
layout.data = self.layout_data
@ -106,94 +125,22 @@ class ReferencePropagationTestCase(EndpointTester):
def test_reference_creation(self):
''' Test reference creation. '''
self.assertEqual(self.operation1.result, self.operation3.result)
self.assertEqual(self.ks1.constituentsQ().count(), 3)
self.assertEqual(self.ks2.constituentsQ().count(), 3)
self.assertEqual(self.ks4.constituentsQ().count(), 6)
self.assertEqual(self.ks5.constituentsQ().count(), 9)
self.assertEqual(self.ks6.constituentsQ().count(), 6)
@decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
def test_delete_target_propagation(self):
''' Test propagation when deleting a target operation. '''
data = {
'layout': self.layout_data,
'target': self.operation1.pk
}
self.executeOK(data=data, item=self.owned_id)
self.assertEqual(self.ks6.constituentsQ().count(), 4)
# self.assertEqual(self.ks5.constituentsQ().count(), 5)
# @decl_endpoint('/api/oss/{item}/delete-reference', method='patch')
# def test_delete_reference_propagation(self):
# ''' Test propagation when deleting a reference operation. '''
# schema_cached = OperationSchemaCached(self.schema2)
# # Ensure reference exists
# self.assertIn(self.reference.pk, schema_cached.cache.operation_by_id)
# # Delete the reference
# schema_cached.delete_reference(self.reference.pk)
# # Reference should be deleted
# with self.assertRaises(Reference.DoesNotExist):
# Reference.objects.get(pk=self.reference.pk)
# # Operation2 should still exist
# self.assertTrue(Operation.objects.filter(pk=self.op2.pk).exists())
# @decl_endpoint('/api/oss/{item}/set-arguments', method='patch')
# def test_set_arguments_propagation(self):
# ''' Test propagation when setting arguments for a reference. '''
# schema_cached = OperationSchemaCached(self.schema2)
# # Add op1 as argument to op2
# schema_cached.set_arguments(self.op2.pk, [self.op1])
# op2 = Operation.objects.get(pk=self.op2.pk)
# args = list(op2.getQ_arguments())
# self.assertEqual(len(args), 1)
# self.assertEqual(args[0].argument, self.op1)
# @decl_endpoint('/api/oss/{item}/delete-operation', method='patch')
# def test_delete_operation_with_reference(self):
# ''' Test propagation when deleting an operation that is referenced. '''
# schema_cached = OperationSchemaCached(self.schema1)
# # op1 is referenced by reference
# self.assertEqual(self.reference.getQ_reference_target(), self.op1)
# # Delete op1
# schema_cached.delete_operation(self.op1.pk)
# # op1 should be deleted
# with self.assertRaises(Operation.DoesNotExist):
# Operation.objects.get(pk=self.op1.pk)
# # Reference should be deleted as well
# self.assertFalse(Reference.objects.filter(pk=self.reference.pk).exists())
# @decl_endpoint('/api/oss/{item}/add-constituent', method='patch')
# def test_add_constituent_propagation(self):
# ''' Test propagation when adding a constituent to a referenced schema. '''
# # Add a new constituent to schema1 (referenced by op1, which is referenced by reference)
# new_cst = Constituenta.objects.create(
# schema=self.schema1, alias='cst_new', title='New Constituenta', cst_type=CstType.ATTRIBUTE
# )
# # Simulate propagation: after adding, the reference should still be valid and schema1 should have the new cst
# self.assertTrue(Constituenta.objects.filter(pk=new_cst.pk, schema=self.schema1).exists())
# # The reference's target operation's result should include the new constituent
# op1 = Operation.objects.get(pk=self.op1.pk)
# self.assertEqual(op1.result, self.schema1)
# self.assertIn(new_cst, Constituenta.objects.filter(schema=op1.result))
# @decl_endpoint('/api/oss/{item}/remove-constituent', method='patch')
# def test_remove_constituent_propagation(self):
# ''' Test propagation when removing a constituent from a referenced schema. '''
# # Remove cst2 from schema1
# self.cst2.delete()
# # The reference's target operation's result should not include cst2
# op1 = Operation.objects.get(pk=self.op1.pk)
# self.assertEqual(op1.result, self.schema1)
# self.assertNotIn(self.cst2, Constituenta.objects.filter(schema=op1.result))
# # Reference should still be valid
# self.assertEqual(self.reference.getQ_reference_target(), self.op1)
# @decl_endpoint('/api/oss/{item}/add-constituent-to-referenced-schema', method='patch')
# def test_propagation_to_multiple_references(self):
# ''' Test propagation when a schema is referenced by multiple references and constituents are added. '''
# # Create another reference to op1
# reference2 = Reference.objects.create(
# alias='ref2', title='Reference 2', type=OperationType.REFERENCE, result=self.schema2
# )
# reference2.setQ_reference_target(self.op1)
# reference2.save()
# # Add a new constituent to schema1
# new_cst = Constituenta.objects.create(
# schema=self.schema1, alias='cst_multi', title='Multi Constituenta', cst_type=CstType.ATTRIBUTE
# )
# # Both references should still be valid and op1's result should include the new constituent
# op1 = Operation.objects.get(pk=self.op1.pk)
# self.assertIn(new_cst, Constituenta.objects.filter(schema=op1.result))
# self.assertEqual(self.reference.getQ_reference_target(), self.op1)
# self.assertEqual(reference2.getQ_reference_target(), self.op1)
# TODO: add more tests

View File

@ -27,6 +27,7 @@ export class OssLoader {
private itemByNodeID = new Map<string, IOssItem>();
private blockByID = new Map<number, IBlock>();
private schemaIDs: number[] = [];
private extendedGraph = new Graph();
constructor(input: RO<IOperationSchemaDTO>) {
this.oss = structuredClone(input) as unknown as IOperationSchema;
@ -47,6 +48,7 @@ export class OssLoader {
result.hierarchy = this.hierarchy;
result.schemas = this.schemaIDs;
result.stats = this.calculateStats();
result.extendedGraph = this.extendedGraph;
return result;
}
@ -57,6 +59,7 @@ export class OssLoader {
this.itemByNodeID.set(operation.nodeID, operation);
this.operationByID.set(operation.id, operation);
this.graph.addNode(operation.id);
this.extendedGraph.addNode(operation.id);
this.hierarchy.addNode(operation.nodeID);
if (operation.parent) {
this.hierarchy.addEdge(constructNodeID(NodeType.BLOCK, operation.parent), operation.nodeID);
@ -75,7 +78,13 @@ export class OssLoader {
}
private createGraph() {
this.oss.arguments.forEach(argument => this.graph.addEdge(argument.argument, argument.operation));
this.oss.arguments.forEach(argument => {
this.graph.addEdge(argument.argument, argument.operation);
this.extendedGraph.addEdge(argument.argument, argument.operation);
});
this.oss.references.forEach(reference => {
this.extendedGraph.addEdge(reference.target, reference.reference);
});
}
private extractSchemas() {
@ -102,7 +111,7 @@ export class OssLoader {
break;
case OperationType.REFERENCE:
const ref = this.oss.references.find(item => item.reference === operationID);
const target = !!ref ? this.oss.operationByID.get(ref.target) : null;
const target = !!ref ? this.operationByID.get(ref.target) : null;
if (!target || !ref) {
throw new Error(`Reference ${operationID} not found`);
}
@ -128,13 +137,13 @@ export class OssLoader {
}
private inferConsolidation(operationID: number): boolean {
const inputs = this.graph.expandInputs([operationID]);
const inputs = this.extendedGraph.expandInputs([operationID]);
if (inputs.length === 0) {
return false;
}
const ancestors = [...inputs];
inputs.forEach(input => {
ancestors.push(...this.graph.expandAllInputs([input]));
ancestors.push(...this.extendedGraph.expandAllInputs([input]));
});
const unique = new Set(ancestors);
return unique.size < ancestors.length;

View File

@ -18,6 +18,9 @@ export function TabArguments() {
} = useFormContext<ICreateSynthesisDTO>();
const inputs = useWatch({ control, name: 'arguments' });
const references = manager.oss.references.filter(item => inputs.includes(item.target)).map(item => item.reference);
const filtered = manager.oss.operations.filter(item => !references.includes(item.id));
return (
<div className='cc-fade-in cc-column'>
<TextInput
@ -64,7 +67,7 @@ export function TabArguments() {
name='arguments'
control={control}
render={({ field }) => (
<PickMultiOperation items={manager.oss.operations} value={field.value} onChange={field.onChange} rows={6} />
<PickMultiOperation items={filtered} value={field.value} onChange={field.onChange} rows={6} />
)}
/>
</div>

View File

@ -1,6 +1,6 @@
'use client';
import { Controller, useForm } from 'react-hook-form';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { HelpTopic } from '@/features/help';
@ -32,6 +32,7 @@ export function DlgDeleteReference() {
keep_connections: false
}
});
const keep_connections = useWatch({ control, name: 'keep_connections' });
function onSubmit(data: IDeleteReferenceDTO) {
return deleteReference({ itemID: oss.id, data: data });
@ -47,19 +48,6 @@ export function DlgDeleteReference() {
helpTopic={HelpTopic.CC_PROPAGATION}
>
<TextInput disabled dense noBorder id='operation_alias' label='Операция' value={target.alias} />
<Controller
control={control}
name='keep_constituents'
render={({ field }) => (
<Checkbox
label='Сохранить наследованные конституенты'
titleHtml='Наследованные конституенты <br/>превратятся в дописанные'
value={field.value}
onChange={field.onChange}
disabled={target.result === null}
/>
)}
/>
<Controller
control={control}
name='keep_connections'
@ -73,6 +61,19 @@ export function DlgDeleteReference() {
/>
)}
/>
<Controller
control={control}
name='keep_constituents'
render={({ field }) => (
<Checkbox
label='Сохранить наследованные конституенты'
titleHtml='Наследованные конституенты <br/>превратятся в дописанные'
value={field.value}
onChange={field.onChange}
disabled={target.result === null || keep_connections}
/>
)}
/>
</ModalForm>
);
}

View File

@ -1,5 +1,5 @@
'use client';
import { Controller, useFormContext } from 'react-hook-form';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { Label } from '@/components/input';
import { useDialogsStore } from '@/stores/dialogs';
@ -12,7 +12,12 @@ import { type DlgEditOperationProps } from './dlg-edit-operation';
export function TabArguments() {
const { control, setValue } = useFormContext<IUpdateOperationDTO>();
const { manager, target } = useDialogsStore(state => state.props as DlgEditOperationProps);
const potentialCycle = [target.id, ...manager.oss.graph.expandAllOutputs([target.id])];
const args = useWatch({ control, name: 'arguments' });
const references = manager.oss.references
.filter(item => args.includes(item.target) || item.target === target.id)
.map(item => item.reference);
const potentialCycle = [target.id, ...references, ...manager.oss.graph.expandAllOutputs([target.id])];
const filtered = manager.oss.operations.filter(item => !potentialCycle.includes(item.id));
function handleChangeArguments(prev: number[], newValue: number[]) {

View File

@ -85,6 +85,7 @@ export interface IOperationSchema extends Omit<IOperationSchemaDTO, 'operations'
blocks: IBlock[];
graph: Graph;
extendedGraph: Graph;
hierarchy: Graph<string>;
schemas: number[];
stats: IOperationSchemaStats;

View File

@ -206,15 +206,32 @@ export function MenuOperation({ operation, onHide }: MenuOperationProps) {
}).then(response => setTimeout(() => setSelected([`o${response.new_operation}`]), PARAMETER.refreshTimeout));
}
function handleSelectTarget() {
onHide();
if (operation.operation_type !== OperationType.REFERENCE) {
return;
}
setSelected([`o${operation.target}`]);
}
return (
<>
<DropdownButton
text='Редактировать'
title='Редактировать операцию'
icon={<IconEdit2 size='1rem' className='icon-primary' />}
onClick={handleEditOperation}
disabled={!isMutable || isProcessing}
/>
{operation.operation_type !== OperationType.REFERENCE ? (
<DropdownButton
text='Редактировать'
title='Редактировать операцию'
icon={<IconEdit2 size='1rem' className='icon-primary' />}
onClick={handleEditOperation}
disabled={!isMutable || isProcessing}
/>
) : (
<DropdownButton
text='Оригинал'
title='Выделить оригинал'
icon={<IconReference size='1rem' className='icon-primary' />}
onClick={handleSelectTarget}
/>
)}
{operation.result ? (
<DropdownButton

View File

@ -109,7 +109,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
}
function canDeleteOperation(target: IOperation) {
if (target.operation_type === OperationType.INPUT) {
if (target.operation_type === OperationType.INPUT || target.operation_type === OperationType.REFERENCE) {
return true;
}
return schema.graph.expandOutputs([target.id]).length === 0;