Building blocks (#1072)

* composite block support

* naming

* Added ability to set node name (or names) for some connectivity components

* cleanup

* Include building blocks in the Recently Used category

* Prompt to confirm the overwrite when saving over the existing building blocks; When saving building blocks, present a drop down list with most recently used building blocks

* add alpha to composite

* Update CompositeComponentTest.java

* cleanup

* Update CompositeComponent.java

* cleanup

* Update CompositeComponent.java

* <org.diylc.appframework.update.Change> 					<changeType>IMPROVEMENT</changeType> 					<description>When saving building blocks, present a drop down list with most recently used building blocks for quick selection</description> 				</org.diylc.appframework.update.Change>

* Diode components now label their terminal nodes as 'A' (anode) and 'K' (cathode) instead of '1' and '2'

* Capacitor components now label their terminal nodes as '+' and '-' instead of '1' and '2' when 'Polarized' property is ticked
This commit is contained in:
Bane Stojkovic
2026-09-04 15:32:46 +02:00
committed by GitHub
parent 26d6ca48fd
commit 047196499a
40 changed files with 1939 additions and 95 deletions
@@ -0,0 +1,224 @@
/*
DIY Layout Creator (DIYLC).
Copyright (c) 2009-2025 held jointly by the individual authors.
This file is part of DIYLC.
DIYLC is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
DIYLC is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
*/
package org.diylc.components.composite;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.awt.Graphics2D;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.util.UUID;
import org.diylc.common.Percentage;
import org.diylc.core.IDIYComponent;
import org.diylc.netlist.Node;
import org.diylc.serialization.ProjectFileManager;
import org.diylc.testcomponents.TwoPointTestComponent;
import org.junit.Test;
/**
* Exercises the delegation, rigidity, cloning and equality behavior described in
* {@code docs/plans/composite-building-blocks.md}.
*/
public class CompositeComponentTest {
private CompositeComponent twoChildComposite() {
CompositeComponent composite = new CompositeComponent();
composite.setName("BLK1");
composite.initBlockName("Test Block");
composite.getChildComponents().add(new TwoPointTestComponent("R1",
new Point2D.Double(0, 0), new Point2D.Double(10, 0)));
composite.getChildComponents().add(new TwoPointTestComponent("R2",
new Point2D.Double(20, 0), new Point2D.Double(30, 0)));
return composite;
}
@Test
public void controlPointCountIsSumOverChildren() {
CompositeComponent composite = twoChildComposite();
assertEquals(4, composite.getControlPointCount());
}
@Test
public void controlPointsMapToTheRightChildAndLocalIndex() {
CompositeComponent composite = twoChildComposite();
assertEquals(new Point2D.Double(0, 0), composite.getControlPoint(0));
assertEquals(new Point2D.Double(10, 0), composite.getControlPoint(1));
assertEquals(new Point2D.Double(20, 0), composite.getControlPoint(2));
assertEquals(new Point2D.Double(30, 0), composite.getControlPoint(3));
}
@Test
public void setControlPointDelegatesToTheOwningChild() {
CompositeComponent composite = twoChildComposite();
composite.setControlPoint(new Point2D.Double(99, 99), 2);
assertEquals(new Point2D.Double(99, 99), composite.getControlPoint(2));
// the sibling child's points must be untouched
assertEquals(new Point2D.Double(30, 0), composite.getControlPoint(3));
TwoPointTestComponent r2 = (TwoPointTestComponent) composite.getChildComponents().get(1);
assertEquals(new Point2D.Double(99, 99), r2.getControlPoint(0));
}
@Test
public void stickinessIsDelegatedPerChildPoint() {
CompositeComponent composite = twoChildComposite();
((TwoPointTestComponent) composite.getChildComponents().get(0)).setSticky(1, false);
assertTrue(composite.isControlPointSticky(0));
assertFalse(composite.isControlPointSticky(1));
assertTrue(composite.isControlPointSticky(2));
assertTrue(composite.isControlPointSticky(3));
}
@Test
public void isAlwaysRigid() {
CompositeComponent composite = twoChildComposite();
for (int i = 0; i < composite.getControlPointCount(); i++)
assertFalse("point " + i + " should not move freely", composite.canPointMoveFreely(i));
}
@Test
public void nodeNamingProducesHierarchicalNetlistLabels() {
// Design decision D3: Node.toString() should read "<composite>.<child>.<local point name>"
CompositeComponent composite = twoChildComposite();
Node r1FirstPoint = new Node(composite, 0);
Node r2FirstPoint = new Node(composite, 2);
Node r2SecondPoint = new Node(composite, 3);
assertEquals("BLK1.1", r1FirstPoint.toString());
assertEquals("BLK1.1", r2FirstPoint.toString());
// local point index must reset per child, not keep growing with the flat composite index
assertEquals("BLK1.2", r2SecondPoint.toString());
}
@Test
public void cloneDeepCopiesChildrenSoInstancesAreIndependent() throws Exception {
CompositeComponent original = twoChildComposite();
IDIYComponent<?> clone = original.clone();
((CompositeComponent) clone).setControlPoint(new Point2D.Double(-1, -1), 0);
assertEquals(new Point2D.Double(0, 0), original.getControlPoint(0));
assertEquals(new Point2D.Double(-1, -1), clone.getControlPoint(0));
}
@Test
public void cloneKeepsBothItsOwnAndChildrenIds() throws Exception {
// Matches AbstractComponent.clone()'s contract of preserving identity by default - a caller
// that needs a genuinely new component (e.g. BuildingBlockManager placing a fresh instance)
// reassigns ids itself, exactly as it does for any other component's clone().
CompositeComponent original = twoChildComposite();
UUID originalChildId = original.getChildComponents().get(0).getId();
CompositeComponent clone = (CompositeComponent) original.clone();
assertEquals(original.getId(), clone.getId());
assertEquals(originalChildId, clone.getChildComponents().get(0).getId());
}
@Test
public void equalsToComparesStructureNotObjectIdentity() throws Exception {
CompositeComponent original = twoChildComposite();
CompositeComponent clone = (CompositeComponent) original.clone();
// structurally identical, distinct child objects - must still compare equal
assertTrue(original.equalsTo(clone));
clone.setControlPoint(new Point2D.Double(-1, -1), 0);
assertFalse(original.equalsTo(clone));
}
@Test
public void equalsToRejectsDifferentBlockName() {
CompositeComponent a = twoChildComposite();
CompositeComponent b = twoChildComposite();
b.initBlockName("Different Block");
assertFalse(a.equalsTo(b));
}
@Test
public void defaultsToFullyOpaque() {
assertEquals(new Percentage(100), twoChildComposite().getAlpha());
}
@Test
public void cloneCopiesAlpha() throws Exception {
CompositeComponent original = twoChildComposite();
original.setAlpha(new Percentage(40));
CompositeComponent clone = (CompositeComponent) original.clone();
assertEquals(new Percentage(40), clone.getAlpha());
}
@Test
public void equalsToRejectsDifferentAlpha() throws Exception {
CompositeComponent original = twoChildComposite();
CompositeComponent clone = (CompositeComponent) original.clone();
assertTrue(original.equalsTo(clone));
clone.setAlpha(new Percentage(50));
assertFalse(original.equalsTo(clone));
}
@Test
public void survivesAnXStreamRoundTripAndRebuildsItsIndexLazily() {
CompositeComponent original = twoChildComposite();
String xml = ProjectFileManager.xStreamSerializer.toXML(original);
CompositeComponent reloaded = (CompositeComponent) ProjectFileManager.xStreamSerializer.fromXML(xml);
// the transient child-index arrays are never serialized - this call exercises the lazy
// rebuild in buildIndexIfNeeded() on an instance XStream constructed without going through
// the no-arg constructor
assertEquals(original.getControlPointCount(), reloaded.getControlPointCount());
assertEquals(original.getName(), reloaded.getName());
assertEquals(original.getBlockName(), reloaded.getBlockName());
for (int i = 0; i < original.getControlPointCount(); i++)
assertEquals(original.getControlPoint(i), reloaded.getControlPoint(i));
assertEquals("BLK1.2", new Node(reloaded, 3).toString());
}
@Test
public void drawIconDoesNotTouchChildren() {
// ComponentProcessor draws the palette icon on a default-constructed, childless instance
CompositeComponent blank = new CompositeComponent();
BufferedImage image = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = image.createGraphics();
blank.drawIcon(g2d, 32, 32);
g2d.dispose();
// no exception means components list (empty) was never dereferenced as if populated
assertNotNull(blank.getChildComponents());
assertTrue(blank.getChildComponents().isEmpty());
}
}
@@ -0,0 +1,177 @@
/*
DIY Layout Creator (DIYLC).
Copyright (c) 2009-2025 held jointly by the individual authors.
This file is part of DIYLC.
DIYLC is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
DIYLC is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
*/
package org.diylc.presenter;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.awt.geom.Point2D;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.diylc.appframework.miscutils.IConfigurationManager;
import org.diylc.common.BlockInstantiationMode;
import org.diylc.common.IBlockProcessor;
import org.diylc.common.IBlockProcessor.InvalidBlockException;
import org.diylc.common.IPlugInPort;
import org.diylc.components.composite.CompositeComponent;
import org.diylc.core.ISwitch;
import org.diylc.core.IDIYComponent;
import org.diylc.testcomponents.SwitchTestComponent;
import org.diylc.testcomponents.TwoPointTestComponent;
import org.junit.Before;
import org.junit.Test;
/**
* Exercises {@link BuildingBlockManager#loadBlock} in both {@link BlockInstantiationMode}s (see
* design decision D10 and section 4.4 of {@code docs/plans/composite-building-blocks.md}).
*/
@SuppressWarnings("unchecked")
public class BuildingBlockManagerTest {
private static final String BLOCK_NAME = "TestBlock";
private IConfigurationManager<?> configManager;
private BuildingBlockManager manager;
@Before
public void setUp() {
List<IDIYComponent<?>> storedComponents = Arrays.asList(
new TwoPointTestComponent("R1", new Point2D.Double(0, 0), new Point2D.Double(10, 0)),
new TwoPointTestComponent("R2", new Point2D.Double(20, 0), new Point2D.Double(30, 0)));
Map<String, List<IDIYComponent<?>>> blocksMap = new HashMap<String, List<IDIYComponent<?>>>();
blocksMap.put(BLOCK_NAME, storedComponents);
configManager = mock(IConfigurationManager.class);
when(configManager.readObject(eq(IPlugInPort.BLOCKS_KEY), org.mockito.ArgumentMatchers.any()))
.thenReturn(blocksMap);
manager = new BuildingBlockManager(configManager, null, new InstantiationManager());
}
@Test
public void groupModeClonesEachComponentWithAFreshNameAndId() throws InvalidBlockException {
List<IDIYComponent<?>> result =
manager.loadBlock(BLOCK_NAME, new ArrayList<IDIYComponent<?>>(), BlockInstantiationMode.GROUP);
assertEquals(2, result.size());
// group mode always assigns fresh, project-unique names (pre-existing behavior, unchanged)
assertEquals("TP1", result.get(0).getName());
assertEquals("TP2", result.get(1).getName());
assertNotEquals(result.get(0).getId(), result.get(1).getId());
}
@Test
public void compositeModeWrapsChildrenInASingleComponent() throws InvalidBlockException {
List<IDIYComponent<?>> result =
manager.loadBlock(BLOCK_NAME, new ArrayList<IDIYComponent<?>>(), BlockInstantiationMode.COMPOSITE);
assertEquals(1, result.size());
assertTrue(result.get(0) instanceof CompositeComponent);
CompositeComponent composite = (CompositeComponent) result.get(0);
assertEquals(BLOCK_NAME, composite.getBlockName());
assertEquals("BLK1", composite.getName());
assertEquals(4, composite.getControlPointCount());
}
@Test
public void compositeModePreservesChildNamesUnlikeGroupMode() throws InvalidBlockException {
List<IDIYComponent<?>> result =
manager.loadBlock(BLOCK_NAME, new ArrayList<IDIYComponent<?>>(), BlockInstantiationMode.COMPOSITE);
CompositeComponent composite = (CompositeComponent) result.get(0);
// children are namespaced under the composite's own name in the netlist, so their saved
// names must survive untouched
assertEquals("R1", composite.getChildComponents().get(0).getName());
assertEquals("R2", composite.getChildComponents().get(1).getName());
}
@Test
public void compositeModeGivesEachInstanceItsOwnNameButChildrenStayIdenticallyNamed()
throws InvalidBlockException {
List<IDIYComponent<?>> existing = new ArrayList<IDIYComponent<?>>();
CompositeComponent first = (CompositeComponent) manager
.loadBlock(BLOCK_NAME, existing, BlockInstantiationMode.COMPOSITE).get(0);
existing.add(first);
CompositeComponent second = (CompositeComponent) manager
.loadBlock(BLOCK_NAME, existing, BlockInstantiationMode.COMPOSITE).get(0);
assertNotEquals(first.getName(), second.getName());
assertEquals(first.getChildComponents().get(0).getName(), second.getChildComponents().get(0).getName());
// but the underlying child components must be distinct objects with distinct ids
assertNotEquals(first.getChildComponents().get(0).getId(), second.getChildComponents().get(0).getId());
}
@Test(expected = InvalidBlockException.class)
public void groupModeThrowsOnUnknownBlock() throws InvalidBlockException {
manager.loadBlock("NoSuchBlock", new ArrayList<IDIYComponent<?>>(), BlockInstantiationMode.GROUP);
}
@Test(expected = InvalidBlockException.class)
public void compositeModeThrowsOnUnknownBlock() throws InvalidBlockException {
manager.loadBlock("NoSuchBlock", new ArrayList<IDIYComponent<?>>(), BlockInstantiationMode.COMPOSITE);
}
@Test
public void deleteBlockAlsoStripsItFromTheRecentlyUsedList() {
List<String> recent = new ArrayList<String>(Arrays.asList(
IBlockProcessor.BLOCK_PREFIX + BLOCK_NAME, "org.diylc.components.SomeComponent"));
when(configManager.readObject(eq(IPlugInPort.RECENT_COMPONENTS_KEY),
org.mockito.ArgumentMatchers.any())).thenReturn(recent);
manager.deleteBlock(BLOCK_NAME);
assertFalse(recent.contains(IBlockProcessor.BLOCK_PREFIX + BLOCK_NAME));
assertTrue(recent.contains("org.diylc.components.SomeComponent"));
verify(configManager).writeValue(eq(IPlugInPort.RECENT_COMPONENTS_KEY), eq(recent));
}
@Test
public void compositeModeToleratesAnInternalSwitchWithoutFailing() throws InvalidBlockException {
// Design decision D6: the composite is not ISwitch, so a block containing a switch must
// still place successfully - it just won't switch (see BuildingBlockManager's log warning).
List<IDIYComponent<?>> storedComponents = Arrays.<IDIYComponent<?>>asList(new SwitchTestComponent("SW1"));
Map<String, List<IDIYComponent<?>>> blocksMap = new HashMap<String, List<IDIYComponent<?>>>();
blocksMap.put("SwitchBlock", storedComponents);
when(configManager.readObject(eq(IPlugInPort.BLOCKS_KEY), org.mockito.ArgumentMatchers.any()))
.thenReturn(blocksMap);
List<IDIYComponent<?>> result = manager.loadBlock("SwitchBlock", new ArrayList<IDIYComponent<?>>(),
BlockInstantiationMode.COMPOSITE);
assertNotNull(result);
assertEquals(1, result.size());
assertTrue(((CompositeComponent) result.get(0)).getChildComponents().get(0) instanceof ISwitch);
}
}
@@ -0,0 +1,65 @@
/*
DIY Layout Creator (DIYLC).
Copyright (c) 2009-2025 held jointly by the individual authors.
This file is part of DIYLC.
DIYLC is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
DIYLC is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
*/
package org.diylc.testcomponents;
import java.awt.geom.Point2D;
import org.diylc.core.ISwitch;
/**
* Smallest possible {@link ISwitch} implementation, used to verify that a building block
* containing a switch can still be instantiated as a {@code CompositeComponent} (design decision
* D6 in {@code docs/plans/composite-building-blocks.md} - the composite itself is not
* {@code ISwitch}, so the switch places and draws but contributes no switching behavior).
* <p>
* Deliberately a public top-level class, not a nested one: {@link
* org.diylc.components.AbstractComponent#clone()} instantiates the no-arg constructor via plain
* reflection (no {@code setAccessible}), which fails across packages unless both the class and
* the constructor are public.
*/
public class SwitchTestComponent extends TwoPointTestComponent implements ISwitch {
private static final long serialVersionUID = 1L;
public SwitchTestComponent() {
this("SW");
}
public SwitchTestComponent(String name) {
super(name, new Point2D.Double(0, 0), new Point2D.Double(10, 0));
}
@Override
public int getPositionCount() {
return 2;
}
@Override
public String getPositionName(int position) {
return position == 0 ? "ON" : "OFF";
}
@Override
public boolean arePointsConnected(int index1, int index2, int position) {
return position == 0;
}
}
@@ -0,0 +1,114 @@
/*
DIY Layout Creator (DIYLC).
Copyright (c) 2009-2025 held jointly by the individual authors.
This file is part of DIYLC.
DIYLC is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
DIYLC is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
*/
package org.diylc.testcomponents;
import java.awt.Graphics2D;
import java.awt.geom.Point2D;
import org.diylc.components.AbstractComponent;
import org.diylc.core.ComponentState;
import org.diylc.core.IDIYComponent;
import org.diylc.core.IDrawingObserver;
import org.diylc.core.Project;
import org.diylc.core.VisibilityPolicy;
import org.diylc.core.annotations.ComponentDescriptor;
/**
* Minimal two-point component used to exercise {@code CompositeComponent} and
* {@code BuildingBlockManager} in tests: two control points, individually configurable
* stickiness, node naming left at the {@link AbstractComponent} default (the 1-based point
* index), so a composite's per-child node name delegation can be verified precisely.
*/
@ComponentDescriptor(name = "Two Point Test", category = "Test", author = "Test",
description = "test", instanceNamePrefix = "TP", zOrder = IDIYComponent.COMPONENT)
public class TwoPointTestComponent extends AbstractComponent<Void> {
private static final long serialVersionUID = 1L;
private Point2D p0;
private Point2D p1;
private boolean sticky0 = true;
private boolean sticky1 = true;
public TwoPointTestComponent() {
this("", new Point2D.Double(0, 0), new Point2D.Double(10, 0));
}
public TwoPointTestComponent(String name, Point2D p0, Point2D p1) {
setName(name);
this.p0 = p0;
this.p1 = p1;
}
public void setSticky(int index, boolean sticky) {
if (index == 0)
sticky0 = sticky;
else
sticky1 = sticky;
}
@Override
public int getControlPointCount() {
return 2;
}
@Override
public Point2D getControlPoint(int index) {
return index == 0 ? p0 : p1;
}
@Override
public void setControlPoint(Point2D point, int index) {
if (index == 0)
p0 = point;
else
p1 = point;
}
@Override
public boolean isControlPointSticky(int index) {
return index == 0 ? sticky0 : sticky1;
}
@Override
public VisibilityPolicy getControlPointVisibilityPolicy(int index) {
return VisibilityPolicy.ALWAYS;
}
@Override
public Void getValue() {
return null;
}
@Override
public void setValue(Void value) {
}
@Override
public void draw(Graphics2D g2d, ComponentState componentState, boolean outlineMode, Project project,
IDrawingObserver drawingObserver) {
}
@Override
public void drawIcon(Graphics2D g2d, int width, int height) {
}
}