phetsims / center-and-variability

"Center and Variability" is an educational simulation in HTML5, by PhET Interactive Simulations.
GNU General Public License v3.0
1 stars 2 forks source link

Excessive coupling to CAVModel #492

Closed pixelzoom closed 1 year ago

pixelzoom commented 1 year ago

For code review #447 ...

  • [ ] Is there any unnecessary coupling? (e.g., by passing large objects to constructors, or exposing unnecessary properties/functions). ...

PlayAreaCheckboxFactory has too much coupling to CAVModel in almost every method. Each method only needs 1 Property, but takes the entire CAVModel as a parameter. For example:

public static getIntervalToolCheckboxItem( model: VariabilityModel ): VerticalCheckboxGroupItem {
...
      property: model.isIntervalToolVisibleProperty,
...

This should be:

public static getIntervalToolCheckboxItem( isIntervalToolVisibleProperty: Property<boolean> ): VerticalCheckboxGroupItem {
...
      property: isIntervalToolVisibleProperty,
...

Not recommended to use (for example) model: Pick<VariabilityModel, 'isIntervalToolVisibleProperty'> in these cases.

pixelzoom commented 1 year ago

Coupling to CAVModel (and its subclasses) seems to be a general problem. I'm finding it in other classes. So I'll re-title this issue, and list the places where I think it should be addressed.

pixelzoom commented 1 year ago

There is excessive coupling to CAVModel in the following places:

... and I gave up from there, because there are so many other cases for subclasses of CAVModel.

Search for "model:" (case insensitive) to locate and inspect.

pixelzoom commented 1 year ago

Here's an example of the problem created by excessive coupling. In CAVPlotNode this ugliness is caused by passing in model: CAVModel:

    const visibleProperty = model instanceof MeanAndMedianModel && options.parentContext === 'accordion' ? model.isMeanVisibleProperty :
                            model instanceof VariabilityModel ? DerivedProperty.valueEqualsConstant( model.selectedVariabilityMeasureProperty, VariabilityMeasure.MAD ) :
                            new BooleanProperty( true );

Why not just pass in the propert value for visibileProperty?

Also noting that these are the only occurrences of instanceof in the sim, and it would nice to eliminate them.

samreid commented 1 year ago

I'll work on this

samreid commented 1 year ago

I worked on this a bit, and wanted to check in before I continue. Here is my patch so far:

```diff Subject: [PATCH] Update documentation style, see https://github.com/phetsims/center-and-variability/issues/490 --- Index: js/common/view/AccordionBoxCheckboxFactory.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/common/view/AccordionBoxCheckboxFactory.ts b/js/common/view/AccordionBoxCheckboxFactory.ts --- a/js/common/view/AccordionBoxCheckboxFactory.ts (revision b654a2935274d00c5e58e0b03785adb4d6626235) +++ b/js/common/view/AccordionBoxCheckboxFactory.ts (date 1692796029509) @@ -19,6 +19,8 @@ import CAVModel from '../model/CAVModel.js'; import MeanIndicatorNode from './MeanIndicatorNode.js'; import nullSoundPlayer from '../../../../tambo/js/shared-sound-players/nullSoundPlayer.js'; +import TReadOnlyProperty from '../../../../axon/js/TReadOnlyProperty.js'; +import CAVSoccerSceneModel from '../model/CAVSoccerSceneModel.js'; // constants const ICON_WIDTH = CAVConstants.CHECKBOX_ICON_DIMENSION; @@ -78,18 +80,18 @@ }; } - public static getMedianCheckboxWithoutIconItem( medianVisibleProperty: Property, model: CAVModel ): VerticalCheckboxGroupItem { + public static getMedianCheckboxWithoutIconItem( medianVisibleProperty: Property, selectedSceneModelProperty: TReadOnlyProperty ): VerticalCheckboxGroupItem { return { createNode: () => CHECKBOX_GROUP.createBox( new Text( CenterAndVariabilityStrings.medianStringProperty, CAVConstants.CHECKBOX_TEXT_OPTIONS ), { xAlign: 'left' } ), property: medianVisibleProperty, tandemName: 'medianCheckbox', options: { - checkedSoundPlayer: PlayAreaCheckboxFactory.getMedianCheckedSoundPlayer( model ) + checkedSoundPlayer: PlayAreaCheckboxFactory.getMedianCheckedSoundPlayer( selectedSceneModelProperty ) } }; } - public static getMeanCheckboxWithIconItem( isTopMeanVisibleProperty: Property, model: CAVModel ): VerticalCheckboxGroupItem { + public static getMeanCheckboxWithIconItem( isTopMeanVisibleProperty: Property, selectedSceneModelProperty: TReadOnlyProperty ): VerticalCheckboxGroupItem { return { createNode: () => { return AccordionBoxCheckboxFactory.createGridBox( @@ -112,7 +114,7 @@ property: isTopMeanVisibleProperty, tandemName: 'meanCheckbox', options: { - checkedSoundPlayer: PlayAreaCheckboxFactory.getMeanCheckedSoundPlayer( model ) + checkedSoundPlayer: PlayAreaCheckboxFactory.getMeanCheckedSoundPlayer( selectedSceneModelProperty ) } }; } Index: js/median/view/MedianAccordionBox.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/median/view/MedianAccordionBox.ts b/js/median/view/MedianAccordionBox.ts --- a/js/median/view/MedianAccordionBox.ts (revision b654a2935274d00c5e58e0b03785adb4d6626235) +++ b/js/median/view/MedianAccordionBox.ts (date 1692796135736) @@ -42,7 +42,7 @@ const checkboxGroup = new VerticalCheckboxGroup( [ AccordionBoxCheckboxFactory.getSortDataCheckboxItem( model.isSortingDataProperty ), - AccordionBoxCheckboxFactory.getMedianCheckboxWithoutIconItem( model.medianVisibleProperty, model ) + AccordionBoxCheckboxFactory.getMedianCheckboxWithoutIconItem( model.medianVisibleProperty, model.selectedSceneModelProperty ) ], { tandem: tandem.createTandem( 'checkboxGroup' ), visiblePropertyOptions: { Index: js/mean-and-median/view/MeanAndMedianAccordionBox.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/mean-and-median/view/MeanAndMedianAccordionBox.ts b/js/mean-and-median/view/MeanAndMedianAccordionBox.ts --- a/js/mean-and-median/view/MeanAndMedianAccordionBox.ts (revision b654a2935274d00c5e58e0b03785adb4d6626235) +++ b/js/mean-and-median/view/MeanAndMedianAccordionBox.ts (date 1692796050544) @@ -53,7 +53,7 @@ const checkboxGroup = CAVConstants.ACCORDION_BOX_VERTICAL_CHECKBOX_GROUP.createBox( new VerticalCheckboxGroup( [ AccordionBoxCheckboxFactory.getMedianCheckboxWithIconItem( model.isMedianVisibleProperty ), - AccordionBoxCheckboxFactory.getMeanCheckboxWithIconItem( model.isMeanVisibleProperty, model ) + AccordionBoxCheckboxFactory.getMeanCheckboxWithIconItem( model.isMeanVisibleProperty, model.selectedSceneModelProperty ) ], { tandem: tandem.createTandem( 'checkboxGroup' ), visiblePropertyOptions: { Index: js/variability/view/VariabilityScreenView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/variability/view/VariabilityScreenView.ts b/js/variability/view/VariabilityScreenView.ts --- a/js/variability/view/VariabilityScreenView.ts (revision b654a2935274d00c5e58e0b03785adb4d6626235) +++ b/js/variability/view/VariabilityScreenView.ts (date 1692796135731) @@ -254,9 +254,9 @@ const controls = this.addPlayAreaControls( new VerticalCheckboxGroup( [ PlayAreaCheckboxFactory.getMedianCheckboxItem( model ), - PlayAreaCheckboxFactory.getMeanCheckboxItem( model ), + PlayAreaCheckboxFactory.getMeanCheckboxItem( model.isPlayAreaMeanVisibleProperty, model.selectedSceneModelProperty ), PlayAreaCheckboxFactory.getPointerCheckboxItem( model ), - PlayAreaCheckboxFactory.getIntervalToolCheckboxItem( model ) + PlayAreaCheckboxFactory.getIntervalToolCheckboxItem( model.isIntervalToolVisibleProperty ) ], { tandem: this.soccerAreaTandem.createTandem( 'checkboxGroup' ), visiblePropertyOptions: { Index: js/mean-and-median/view/MeanAndMedianScreenView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/mean-and-median/view/MeanAndMedianScreenView.ts b/js/mean-and-median/view/MeanAndMedianScreenView.ts --- a/js/mean-and-median/view/MeanAndMedianScreenView.ts (revision b654a2935274d00c5e58e0b03785adb4d6626235) +++ b/js/mean-and-median/view/MeanAndMedianScreenView.ts (date 1692796440778) @@ -51,10 +51,10 @@ this.setAccordionBox( meanAndMedianAccordionBox ); const controls = this.addPlayAreaControls( new VerticalCheckboxGroup( [ - PlayAreaCheckboxFactory.getPredictMedianCheckboxItem( model ), - PlayAreaCheckboxFactory.getPredictMeanCheckboxItem( model ), - PlayAreaCheckboxFactory.getMedianCheckboxItem( model ), - PlayAreaCheckboxFactory.getMeanCheckboxItem( model ) + PlayAreaCheckboxFactory.getPredictMedianCheckboxItem( model.isPredictMedianVisibleProperty ), + PlayAreaCheckboxFactory.getPredictMeanCheckboxItem( model.isPredictMeanVisibleProperty ), + PlayAreaCheckboxFactory.getMedianCheckboxItem( model.isPlayAreaMedianVisibleProperty, model.selectedSceneModelProperty ), + PlayAreaCheckboxFactory.getMeanCheckboxItem( model.isPlayAreaMeanVisibleProperty, model.selectedSceneModelProperty ) ], { tandem: this.soccerAreaTandem.createTandem( 'checkboxGroup' ), visiblePropertyOptions: { Index: js/common/view/PlayAreaCheckboxFactory.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/js/common/view/PlayAreaCheckboxFactory.ts b/js/common/view/PlayAreaCheckboxFactory.ts --- a/js/common/view/PlayAreaCheckboxFactory.ts (revision b654a2935274d00c5e58e0b03785adb4d6626235) +++ b/js/common/view/PlayAreaCheckboxFactory.ts (date 1692796440775) @@ -26,6 +26,10 @@ import SoccerCommonColors from '../../../../soccer-common/js/SoccerCommonColors.js'; import PhetioProperty from '../../../../axon/js/PhetioProperty.js'; import VariabilityMeasureIconNode from '../../variability/view/VariabilityMeasureIconNode.js'; +import CAVSoccerBall from '../model/CAVSoccerBall.js'; +import CAVSoccerSceneModel from '../model/CAVSoccerSceneModel.js'; +import TReadOnlyProperty from '../../../../axon/js/TReadOnlyProperty.js'; +import TProperty from '../../../../axon/js/TProperty.js'; // constants const TEXT_OPTIONS = { @@ -50,7 +54,7 @@ } ); } - public static getIntervalToolCheckboxItem( model: VariabilityModel ): VerticalCheckboxGroupItem { + public static getIntervalToolCheckboxItem( visibleProperty: Property ): VerticalCheckboxGroupItem { return { createNode: () => { return PlayAreaCheckboxFactory.createGridBox( @@ -60,15 +64,15 @@ new VariabilityMeasureIconNode( CAVColors.intervalToolIconRectangleFillColorProperty, CAVConstants.CHECKBOX_ICON_DIMENSION - 5 ) ); }, - property: model.isIntervalToolVisibleProperty, + property: visibleProperty, tandemName: 'intervalToolCheckbox' }; } - public static getMedianCheckedSoundPlayer( model: CAVModel ): TSoundPlayer { + public static getMedianCheckedSoundPlayer( selectedSceneModelProperty: TReadOnlyProperty ): TSoundPlayer { return { play: () => { - const median = model.selectedSceneModelProperty.value.medianValueProperty.value; + const median = selectedSceneModelProperty.value.medianValueProperty.value; if ( median !== null ) { NumberTone.playMedian( median ); } @@ -82,10 +86,10 @@ }; } - public static getMeanCheckedSoundPlayer( model: CAVModel ): TSoundPlayer { + public static getMeanCheckedSoundPlayer( selectedSceneModelProperty: TReadOnlyProperty ): TSoundPlayer { return { play: () => { - const mean = model.selectedSceneModelProperty.value.meanValueProperty.value; + const mean = selectedSceneModelProperty.value.meanValueProperty.value; if ( mean !== null ) { NumberTone.playMean( mean ); } @@ -99,7 +103,7 @@ }; } - public static getMedianCheckboxItem( model: CAVModel ): VerticalCheckboxGroupItem { + public static getMedianCheckboxItem( isPlayAreaMedianVisibleProperty: Property, selectedSceneModelProperty: Property ): VerticalCheckboxGroupItem { return { createNode: () => { @@ -114,23 +118,23 @@ maxHeight: CAVConstants.CHECKBOX_ICON_DIMENSION - 4 } ) ); }, - property: model.isPlayAreaMedianVisibleProperty, + property: isPlayAreaMedianVisibleProperty, tandemName: 'medianCheckbox', options: { - checkedSoundPlayer: PlayAreaCheckboxFactory.getMedianCheckedSoundPlayer( model ) + checkedSoundPlayer: PlayAreaCheckboxFactory.getMedianCheckedSoundPlayer( selectedSceneModelProperty ) } }; } - public static getMeanCheckboxItem( model: CAVModel ): VerticalCheckboxGroupItem { + public static getMeanCheckboxItem( isPlayAreaMeanVisibleProperty: Property, selectedSceneModelProperty: TReadOnlyProperty ): VerticalCheckboxGroupItem { return { createNode: () => PlayAreaCheckboxFactory.createGridBox( new Text( CenterAndVariabilityStrings.meanStringProperty, TEXT_OPTIONS ), new MeanIndicatorNode( true, true ) ), - property: model.isPlayAreaMeanVisibleProperty, + property: isPlayAreaMeanVisibleProperty, tandemName: 'meanCheckbox', options: { - checkedSoundPlayer: PlayAreaCheckboxFactory.getMeanCheckedSoundPlayer( model ) + checkedSoundPlayer: PlayAreaCheckboxFactory.getMeanCheckedSoundPlayer( selectedSceneModelProperty ) } }; } @@ -148,18 +152,18 @@ }; } - public static getPredictMedianCheckboxItem( model: CAVModel ): VerticalCheckboxGroupItem { + public static getPredictMedianCheckboxItem( isPredictMedianVisibleProperty: Property ): VerticalCheckboxGroupItem { return PlayAreaCheckboxFactory.createPredictionItem( - model.isPredictMedianVisibleProperty, + isPredictMedianVisibleProperty, CenterAndVariabilityStrings.predictMedianStringProperty, CAVColors.medianColorProperty, 'predictMedianCheckbox' ); } - public static getPredictMeanCheckboxItem( model: MeanAndMedianModel ): VerticalCheckboxGroupItem { + public static getPredictMeanCheckboxItem( isPredictMeanVisibleProperty: Property ): VerticalCheckboxGroupItem { return PlayAreaCheckboxFactory.createPredictionItem( - model.isPredictMeanVisibleProperty, + isPredictMeanVisibleProperty, CenterAndVariabilityStrings.predictMeanStringProperty, CAVColors.meanColorProperty, 'predictMeanCheckbox' ```

I wanted to raise this for discussion with @marlitas and @matthew-blackman because I'm concerned this opens up opportunity to pass the incorrect Property at the call sites. For example, one diff is:

image

So I'm wondering if we would prefer a Pick at the declaration sites.

Also, I'm not convinced this is better for readability at the implementation sites:

image

pixelzoom commented 1 year ago

... I'm concerned this opens up opportunity to pass the incorrect Property at the call sites

Yes, that's certainly possible. But it's much less of a concern than passing in an entire CAVModel when you only need a couple of its properties.

samreid commented 1 year ago

Would a Pick be the "best of both worlds"?

pixelzoom commented 1 year ago

In general, I'm not a fan of using Pick for this. It does prevent the implementation from accessing fields that it shouldn't be using. But it still creates unnecessary coupling, because the caller is required to provide an object of a wider type than is really needed. If you want to use Pick here in sim-specific code (center-and-variability) that's up to you. But I definitely recommend against it in common code.

samreid commented 1 year ago

But it still creates unnecessary coupling, because the caller is required to provide an object of a wider type than is really needed.

It requires that the client code pass in an object with a named key. Not sure how that could be considered a wider type.

samreid commented 1 year ago

In discussion with @marlitas and @matthew-blackman and myself, we are leaning toward using Pick in circumstances where we don't need flexibility around the key name.

We did discuss, however, that at runtime (during debugging), the entire model would be available in the Pick site implementations.

Also, wanted to clarify with where @pixelzoom remarked:

But it still creates unnecessary coupling

How does the Pick create the unnecessary coupling, or are you referring to coupling of the key name?

pixelzoom commented 1 year ago

How does the Pick create the unnecessary coupling, or are you referring to coupling of the key name?

model: Pick<CAVModel, ...> is coupling. You are now dependent on the API of CAVModel, in a place where you don't need to be. Again it's not as bad as model: CAVModel, but it's unnecessary coupling.

So... Do what you'd like. I'm still not a fan of Pick when there are only a couple of fields to be passed.

pixelzoom commented 1 year ago

As an example, consider PlayAreaCheckboxFactory. getMedianCheckedSoundPlayer. Its implementation requires 1 Property, medianValueProperty: TReadOnlyProperty<number>.

I really don't understand why you would do change this:

public static getMedianCheckedSoundPlayer( model: CAVModel ): TSoundPlayer {

... to this:

public static getMedianCheckedSoundPlayer( model: Pick<CAVModel, 'medianValueProperty'> ): TSoundPlayer {

... instead of this:

public static getMedianCheckedSoundPlayer( medianValueProperty: TReadOnlyProperty<number> ): TSoundPlayer {

Using Pick has the following problems:

And based on the "Pick is better because it prevents you from passing in the wrong Property" argument, you would never have parameters of the form somethingProperty: Property<someType>. So I don't really buy that argument.

pixelzoom commented 1 year ago

Rather than Pick<CAVModel,...>, here's another approach that addresses some of the problems identified in the preceeding comment.

Here's the Pick<CAVModel,...> approach:

class SomeClass {
  public constructor( model: Pick<CAVModel, 'isPlayAreaMedianVisibleProperty'> ) { 
    // reads the value of isPlayAreaMedianVisibleProperty
  }
}

Instead of involving CAVModel here, define a Model type that includes only the fields needed, with the proper mutable/read-only Property type as needed.

type SomeClassModel = {
  isPlayAreaMedianVisibleProperty: TReadOnlyProperty<boolean>;
};

class SomeClass {
  public constructor( model: SomeClassModel ) { 
    // reads the value of isPlayAreaMedianVisibleProperty 
  }
}

So... am I advocating this? No. After writing the above, I realize that it doesn't prevent you from writing to the fields in SomeClassModel, because there's no notion of readonly in a type.

samreid commented 1 year ago

I am also not advocating this, but if someone wanted to follow that approach, they could mark the attribute readonly

type SomeClassModel = {
  readonly isPlayAreaMedianVisibleProperty: TReadOnlyProperty<boolean>;
};
samreid commented 1 year ago

OK this is implemented as @pixelzoom recommended and ready for spot-check. Please close if all is well.

UPDATE: Did not change CAVSceneView or even use Pick there since it uses 3 properties locally and several more in the parent.

samreid commented 1 year ago

UPDATE: I'm seeing subclasses of CAVModel that could get the same pattern. Self-assigning to work on those.

samreid commented 1 year ago

OK I addressed several other cases where it was better to pass through individual Property instances. Ready for review again this time, I think.

pixelzoom commented 1 year ago

Much better! A couple of remaining cases, up to you whether you want to address them. Close when done.

samreid commented 1 year ago

Thanks, I fixed the first case as recommended.

Regarding the 2nd case:

CardModel is gettng the entire cardContainerModel: CardContainerModel as a constructor parameter only so that it can do cardContainerModel.parentContext === 'accordion'.

That is not the only reason. It is also a constructor parameter declared via public constructor( public readonly cardContainerModel: CardContainerModel so it is a public class attribute. It is accessed 3x in CardNode:

So I feel that occurrence should remain. @pixelzoom OK to close?

pixelzoom commented 1 year ago

👍🏻