From 0df19df12bc10640137174ff62d0be390c233be0 Mon Sep 17 00:00:00 2001 From: icecream17 Date: Sun, 24 Oct 2021 21:23:13 -0500 Subject: [PATCH] error bounds --- .eslintrc | 6 +- CHANGELOG.md | 8 ++ hmmStrat.md | 9 +- package.json | 9 +- src/Api/Solver.ts | 58 ++++----- src/Api/Spaces/PureSudoku.test.ts | 6 +- src/Api/Spaces/PureSudoku.ts | 7 +- src/Api/Strategies/updateCandidates.ts | 17 ++- src/App.css | 8 +- src/App.tsx | 23 +--- src/Elems/AsideElems/SolverPart.tsx | 2 +- src/Elems/AsideElems/StrategyItem.tsx | 11 +- src/Elems/AsideElems/StrategyLabel.tsx | 5 +- src/Elems/AsideElems/StrategyTogglerLabel.tsx | 5 +- src/Elems/ExternalLink.tsx | 7 +- src/Elems/Main.tsx | 4 +- src/Elems/MainElems/Candidate.tsx | 4 +- src/Elems/MainElems/Candidates.tsx | 40 +++--- src/Elems/MainElems/CandidatesDiff.tsx | 18 +-- src/Elems/MainElems/Cell.tsx | 90 +++++++------- src/Elems/MainElems/Row.tsx | 4 +- src/Elems/NoticeElems/NoticeWindow.tsx | 7 +- src/Elems/Version.tsx | 2 +- src/ErrorNotice.css | 9 ++ src/ErrorNotice.tsx | 41 +++++++ src/index.tsx | 5 +- src/utils.test.ts | 9 +- src/utils.ts | 20 ++- yarn.lock | 115 +++++++++++++++--- 29 files changed, 343 insertions(+), 206 deletions(-) create mode 100644 src/ErrorNotice.css create mode 100644 src/ErrorNotice.tsx diff --git a/.eslintrc b/.eslintrc index 452087c9..d5186df6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -41,7 +41,8 @@ "regexp", "sonarjs", "@typescript-eslint", - "unicorn" + "unicorn", + "write-good-comments" ], "reportUnusedDisableDirectives": true, "rules": { @@ -123,6 +124,9 @@ "unicorn/throw-new-error": "off", + "write-good-comments/write-good-comments": "off", + + // disabling base rules "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": "error" diff --git a/CHANGELOG.md b/CHANGELOG.md index 041171bf..31c21bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Note: Many earlier versions are not specified, that's too much work. When a `@types` dependency updates, they almost always don't affect anything. +## v0.26.5 + +- (BUG, USE) Fix skipping a strategy +- (BUG, USE) For some reason the candidates didn't highlight. Fixed that. +- (ui) Lessen the impact of gigantic loadings. +- (code) Rename `_to81` to `to81` +- (docs) Slightly better docs in many files + ## v0.26.4 - (use, a11y) `Ctrl+Home` goes to the first cell and `Ctrl+End` goes to the last cell diff --git a/hmmStrat.md b/hmmStrat.md index abca2f4b..b7ea4486 100644 --- a/hmmStrat.md +++ b/hmmStrat.md @@ -67,13 +67,10 @@ A1=1 Ok, that's the end of the partial list. -Obviously that's way too long!\ -Unless you're a computer. +Wayy too long!\ +(Unless you're a computer.) -Even if computers had the capacity to be bored, it would take 1 millisecond to process this list.\ -After all, it's not even 0.01 megabytes. -The computers aren't gonna get bored.\ -And anyways, the list can be simplified as candidates are removed. +But at least the list can be simplified as candidates are removed. Let's look at the Easter Monster sudoku diff --git a/package.json b/package.json index 674077af..1c5f3175 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solver", - "version": "0.26.4", + "version": "0.26.5", "private": true, "homepage": "https://icecream17.github.io/solver", "dependencies": { @@ -17,14 +17,15 @@ }, "devDependencies": { "@types/jest": "^27.0.2", - "@types/node": "^16.11.1", - "@types/react": "^17.0.30", - "@types/react-dom": "^17.0.9", + "@types/node": "^16.11.4", + "@types/react": "^17.0.32", + "@types/react-dom": "^17.0.10", "eslint-plugin-jest-dom": "^3.9.2", "eslint-plugin-react-perf": "^3.3.0", "eslint-plugin-regexp": "^1.4.1", "eslint-plugin-sonarjs": "^0.10.0", "eslint-plugin-unicorn": "^37.0.1", + "eslint-plugin-write-good-comments": "^0.1.3", "source-map-explorer": "^2.5.2" }, "scripts": { diff --git a/src/Api/Solver.ts b/src/Api/Solver.ts index 8190dfba..4dd3edd1 100644 --- a/src/Api/Solver.ts +++ b/src/Api/Solver.ts @@ -22,10 +22,10 @@ export default class Solver { strategyIndex = 0 latestStrategyItem: null | StrategyItem = null - /** When a strategyItem is about to unmount, the strategy item element is deleted. */ + /** When a StrategyItem is about to unmount, it deletes its reference here */ strategyItemElements: Array = [] - /** Used so that when there are multiple steps at the same time, the latter steps can wait */ + /** Later steps wait for earlier steps to finish. Implemented using callback and promises */ whenStepHasFinished: _Callback[] = [] isDoingStep = false stepsTodo = 0 @@ -40,7 +40,7 @@ export default class Solver { skippable = [] as boolean[] constructor(public sudoku: Sudoku) { - // These capitalized methods are used as handlers in StrategyControls, so they need to be bound beforehand. + // Bind the StrategyControl handlers which have capitzalized names this.Go = this.Go.bind(this) this.Step = this.Step.bind(this) this.Undo = this.Undo.bind(this) @@ -52,8 +52,8 @@ export default class Solver { /** * !async * - * Called after the last strategy is done, - * and just before the first strategy is done. + * Called when starting a new {@link Solver.prototype.Go} of strategies, i.e. starting strategy 0. + * Resets the StrategyResults in practice. */ resetStrategies() { const promises = [] as Array> @@ -73,10 +73,8 @@ export default class Solver { } updateCounters(success: boolean, isFinished: boolean) { - // Go back to the start when a strategy succeeds + // Go back to the start when a strategy succeeds, if erroring, or if finished // (exception 1: if you're at the start go to 1 anyways) - // (exception 1a: if the sudoku is finished don't go to 1) - // (exception 1b: always be at the start if erroring) // (exception 2: // After "check for solved" fails, // skip "update candidates" @@ -90,9 +88,9 @@ export default class Solver { this.skippable[this.strategyIndex] = true } - // if exception (not 1) / 1a / 1b - // else if 2 - // else + // if GoToStart + // else if Exception2 + // else if ((success && this.strategyIndex > 0) || this.erroring || isFinished) { this.strategyIndex = 0 } else if (this.strategyIndex === 0 && success === false) { @@ -114,15 +112,19 @@ export default class Solver { } } - // *async + /** + * *async + */ setupCells() { const promises = [] as Promise[] for (const row of this.sudoku.cells) { for (const cell of row) { - promises.push(new Promise(resolve => { - cell?.setExplainingToTrue(resolve) ?? resolve(undefined) - })) + if (cell != null) { + promises.push(new Promise(resolve => { + cell.setExplainingToTrue(resolve) + })) + } } } @@ -132,7 +134,7 @@ export default class Solver { /** * !async * - * Kind of a misnomer really. + * !misnomer * * For each cell, run {@link Cell#setExplainingToFalse} */ @@ -145,7 +147,7 @@ export default class Solver { await forComponentsToUpdate() } - private async StartStep () { + private async StartStep (): Promise { await forComponentsToUpdate() this.isDoingStep = true @@ -166,9 +168,9 @@ export default class Solver { "The code somehow can't find the Strategy Item", AlertType.ERROR ) - // Only error if not null. - // Otherwise, this is only because the StrategyItem unloaded, e.g. when exiting the tab - // (Really because of tests) + // If the StrategyItem unloaded, it's null, right? + // e.g. when exiting the tab + // esp. when finishing a test if (this.latestStrategyItem !== null) { this.latestStrategyItem = null console.error(`undefined strategyItemElement @${this.strategyIndex}`) @@ -176,12 +178,10 @@ export default class Solver { } else { this.latestStrategyItem = this.strategyItemElements[this.strategyIndex] as StrategyItem - // Don't run strategy if it's disabled, - // instead move on to the next strategy + // Skip disabled strategies if (this.latestStrategyItem.state.disabled) { this.updateCounters(false, false) - this.isDoingStep = false // Set back in the next step - return this.Step() // Return other step promise + return await this.StartStep() } // Not disabled, so update state @@ -226,11 +226,11 @@ export default class Solver { async Step(): Promise { this.erroring = false - // Code for multiple steps at the same time + // Let's not do multiple steps at the same time if (this.isDoingStep) { this.stepsTodo++ - // Wait for the current step to finish + // Wait for any previous steps to finish // After that, continue to the main code await new Promise(resolve => { this.whenStepHasFinished.push(resolve) @@ -249,7 +249,7 @@ export default class Solver { await this.FinishStep(strategyResult) - // Code for multiple steps at the same time + // Do the next step if it's waiting for this one if (this.stepsTodo > 0) { this.stepsTodo-- this.whenStepHasFinished[0]() @@ -281,7 +281,7 @@ export default class Solver { async Import() { const result = await asyncPrompt("Import", "Enter digits or candidates") if (result === null || result === "") { - return; // Maybe do something else + return; // Don't import on cancel } await this.reset() @@ -289,7 +289,7 @@ export default class Solver { } Export() { - window._custom.alert(this.sudoku._to81()) + window._custom.alert(this.sudoku.to81()) window._custom.alert(this.sudoku.to729()) } diff --git a/src/Api/Spaces/PureSudoku.test.ts b/src/Api/Spaces/PureSudoku.test.ts index f8cc0cb6..38416ba1 100644 --- a/src/Api/Spaces/PureSudoku.test.ts +++ b/src/Api/Spaces/PureSudoku.test.ts @@ -18,7 +18,7 @@ test('it imports', () => { console.debug(board) } expect(testSudoku.import(board).success).toBe(true) - expect(testSudoku._to81()).toBe(new PureSudoku(board)._to81()) + expect(testSudoku.to81()).toBe(new PureSudoku(board).to81()) } expect(testSudoku.import(`https://www.sudokuwiki.org/sudoku.htm?bd=000000001004060208070320400900018000005000600000540009008037040609080300100000000`).success).toBe(true) @@ -62,11 +62,11 @@ test('getBox', () => { expect(testSudoku.getBox(2)[2]).toStrictEqual([1, 2, 3]) }) -test('_to81', () => { +test('to81', () => { const testSudoku = new PureSudoku() testSudoku.set(1, 2).to(3) const toSudoku = new PureSudoku() - expect(toSudoku.import(testSudoku._to81())).toStrictEqual({ + expect(toSudoku.import(testSudoku.to81())).toStrictEqual({ success: true, representationType: '81' }) diff --git a/src/Api/Spaces/PureSudoku.ts b/src/Api/Spaces/PureSudoku.ts index f7d90602..ae7ab346 100644 --- a/src/Api/Spaces/PureSudoku.ts +++ b/src/Api/Spaces/PureSudoku.ts @@ -27,9 +27,9 @@ export default class PureSudoku { } /** - * Currently for debugging + * Convert the sudoku into 81 digits, 0 for an unsolved cell */ - _to81 () { + to81 () { let str = "" for (const row of this.data) { for (const cell of row) { @@ -47,8 +47,7 @@ export default class PureSudoku { } /** - * Currently for debugging - * But also used in the "export" button + * Convert the sudoku into 729 candidates, 0 for an eliminated one */ to729 () { let str = "" diff --git a/src/Api/Strategies/updateCandidates.ts b/src/Api/Strategies/updateCandidates.ts index 85eb7d3a..e49ecfec 100644 --- a/src/Api/Strategies/updateCandidates.ts +++ b/src/Api/Strategies/updateCandidates.ts @@ -1,11 +1,12 @@ import { INDICES_TO_NINE } from "../../Types" import PureSudoku from "../Spaces/PureSudoku" import { SuccessError } from "../Types" -import { affects, algebraic } from "../Utils" +import { affects, algebraic, CellID } from "../Utils" // O(n^5) export default function updateCandidates(sudoku: PureSudoku) { let updated = 0 + const newResults = new Set() for (const i of INDICES_TO_NINE) { for (const j of INDICES_TO_NINE) { @@ -16,10 +17,10 @@ export default function updateCandidates(sudoku: PureSudoku) { const solvedCandidate = sudoku.data[i][j][0] // Cell > Affects - for (const {row, column} of affects(i, j)) { + for (const id of affects(i, j)) { // Cell > Affects > Cell - const datacell = sudoku.data[row][column] + const datacell = sudoku.data[id.row][id.column] const tempIndex = datacell.indexOf(solvedCandidate) // If has candidate @@ -29,13 +30,13 @@ export default function updateCandidates(sudoku: PureSudoku) { return { success: false, successcount: SuccessError, - message: `Both ${algebraic(i, j)} and ${algebraic(row, column)} must be ${solvedCandidate}` + message: `Both ${algebraic(i, j)} and ${algebraic(id.row, id.column)} must be ${solvedCandidate}` } } - updated++ datacell.splice(tempIndex, 1) // Deletes the candidate - sudoku.set(row, column).to(...datacell) // Updates/renders the cell too + newResults.add(id) + updated++ } } } @@ -43,6 +44,10 @@ export default function updateCandidates(sudoku: PureSudoku) { } if (updated > 0) { + for (const {row, column} of newResults) { + sudoku.set(row, column).to(...sudoku.data[row][column]) // Don't run Cell#setState on every single candidate removal + } + return { success: true, successcount: updated diff --git a/src/App.css b/src/App.css index 5645d228..522d984b 100644 --- a/src/App.css +++ b/src/App.css @@ -26,14 +26,16 @@ .App { background-color: var(--background-color); - min-height: 100vh; - min-width: 100vw; - width: fit-content; color: var(--text-color); /* For the github-corner */ position: relative; + min-height: 100vh; + min-width: 100vw; + width: fit-content; + margin: 0; + display: grid; gap: 1rem; align-content: space-between; diff --git a/src/App.tsx b/src/App.tsx index 23ef3b22..e386823a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,11 +29,15 @@ declare global { interface Map { has (key: unknown): CouldAIsB } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars --- I can't prefix this with an underscore, _typescript_ + interface Array { + slice(start?: number, end?: number): this + } } type AppState = { - error: boolean notices: NoticeInfo[] } @@ -62,11 +66,6 @@ class App extends React.Component<_UnusedProps, AppState> { */ this.sudoku = new SudokuData() this.state = { - /** - * If the App has caught an error - */ - error: false, - /** * The queue of alert and prompt messages to be sent * Only 1 is displayed at a time @@ -94,19 +93,9 @@ class App extends React.Component<_UnusedProps, AppState> { window._custom.prompt = () => undefined; } - componentDidCatch (error: Error, errorInfo: React.ErrorInfo) { - console.error("App crashed", { error, errorInfo }); - this.setState({ error: true }) - } - render () { - const classNames = ["App"] - if (this.state.error) { - classNames.push("error") - } - return ( -
+
<Version /> diff --git a/src/Elems/AsideElems/SolverPart.tsx b/src/Elems/AsideElems/SolverPart.tsx index 2480c644..b47ec5de 100644 --- a/src/Elems/AsideElems/SolverPart.tsx +++ b/src/Elems/AsideElems/SolverPart.tsx @@ -54,7 +54,7 @@ export default class SolverPart extends React.Component<SolverPartProps> { this.children.list = list } - /** Called when a strategy is tried - see the Solver api */ + /** Called when a strategy starts - see the Solver api */ notify(strategyIndex: number, strategyResult: StrategyResult) { this.strategyItemStates[strategyIndex] = strategyResult } diff --git a/src/Elems/AsideElems/StrategyItem.tsx b/src/Elems/AsideElems/StrategyItem.tsx index 982eeeed..fd3b7e75 100644 --- a/src/Elems/AsideElems/StrategyItem.tsx +++ b/src/Elems/AsideElems/StrategyItem.tsx @@ -64,10 +64,9 @@ export default class StrategyItem extends React.Component<StrategyItemProps, Str /** * a11y considerations: * - * I want the strategy toggler checkbox to be - * activatable by just clicking the whole text. + * I want the checkbox to be togglable by clicking any part of the text. * - * But the whole text isn't a good label. + * But the text itself isn't a good label; * A better label would be "toggle strategyName" instead of "strategyName" * * Also, "strategyName" should label the <li> rather than the checkbox @@ -81,7 +80,7 @@ export default class StrategyItem extends React.Component<StrategyItemProps, Str } const togglerPart = this.props.required ? <></> : ( - // eslint-disable-next-line jsx-a11y/label-has-for --- Obviously both nesting and id are associated + // eslint-disable-next-line jsx-a11y/label-has-for --- Both nesting and id are right there!!!!! <label htmlFor={ this.togglerId as string }> <StrategyTogglerLabel {...this.props} /> <StrategyToggler callback={this.toggle} id={this.togglerId as string} /> @@ -96,10 +95,10 @@ export default class StrategyItem extends React.Component<StrategyItemProps, Str </li> ) - // StrategyLabel is placed before StrategyToggler because + // StrategyLabel goes before StrategyToggler because // it makes sense a11y wise to put the text first - // And theoretically, the site both supports ltr and rtl + // And also because the site supports both ltr and rtl (hopefully) } toggle(_event: React.ChangeEvent) { diff --git a/src/Elems/AsideElems/StrategyLabel.tsx b/src/Elems/AsideElems/StrategyLabel.tsx index 78edfdf8..bbdc1cac 100644 --- a/src/Elems/AsideElems/StrategyLabel.tsx +++ b/src/Elems/AsideElems/StrategyLabel.tsx @@ -10,10 +10,7 @@ export type StrategyLabelProps = Readonly<{ }> /** - * The text "labelling" or really naming, a strategy - * - * Really it's just the text inside the StrategyItem, - * besides StrategyResult + * The text {labelling} (naming) [for] a strategy; inside such StrategyItem. */ export default class StrategyLabel extends React.PureComponent<StrategyLabelProps> { render() { diff --git a/src/Elems/AsideElems/StrategyTogglerLabel.tsx b/src/Elems/AsideElems/StrategyTogglerLabel.tsx index 1a3c31ac..defc1ca2 100644 --- a/src/Elems/AsideElems/StrategyTogglerLabel.tsx +++ b/src/Elems/AsideElems/StrategyTogglerLabel.tsx @@ -8,10 +8,7 @@ export type StrategyTogglerLabelProps = Readonly<{ }> /** - * The text "labelling" or really naming, a strategy - * - * Really it's just the text inside the StrategyItem, - * besides StrategyResult + * Same as {@link StrategyLabel}, but this time labelling the {@link StrategyToggler} */ export default class StrategyTogglerLabel extends React.PureComponent<StrategyTogglerLabelProps> { render() { diff --git a/src/Elems/ExternalLink.tsx b/src/Elems/ExternalLink.tsx index e1efca78..cdeb6236 100644 --- a/src/Elems/ExternalLink.tsx +++ b/src/Elems/ExternalLink.tsx @@ -9,10 +9,11 @@ type ExternalLinkProps = Readonly<{ }> /** - * An external link. - * Since the link is external it'll open up in a new tab. + * Opens in a new tab (external link, right?) * - * It is required to have both the "content" and "href" props + * @requiredProps + * - children + * - href * * @example * <ExternalLink href="https://reactjs.org" content="Learn react"/> diff --git a/src/Elems/Main.tsx b/src/Elems/Main.tsx index ed8c54fe..6c6e58a3 100644 --- a/src/Elems/Main.tsx +++ b/src/Elems/Main.tsx @@ -9,9 +9,7 @@ type MainProps = Readonly<{ }> /** - * The "main" component, which is just the sudoku parts for now. - * - * Currently the parts are Coords and Sudoku + * The "main" component; same as Coords + Sudoku for now * TODO: Remove coords */ class Main extends React.Component<MainProps> { diff --git a/src/Elems/MainElems/Candidate.tsx b/src/Elems/MainElems/Candidate.tsx index e134cb32..2a77a595 100644 --- a/src/Elems/MainElems/Candidate.tsx +++ b/src/Elems/MainElems/Candidate.tsx @@ -1,12 +1,12 @@ import './Candidate.css' import React from 'react'; -import { IndexToNine, _ReactProps } from '../../Types'; +import { IndexToNine } from '../../Types'; type CandidateProps = Readonly<{ index: IndexToNine className?: string -}> & _ReactProps +}> /** * A cell candidate diff --git a/src/Elems/MainElems/Candidates.tsx b/src/Elems/MainElems/Candidates.tsx index 1709016b..9bdaa1de 100644 --- a/src/Elems/MainElems/Candidates.tsx +++ b/src/Elems/MainElems/Candidates.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { IndexToNine, INDICES_TO_NINE, SudokuDigits, _ReactProps } from '../../Types'; +import { IndexToNine, SudokuDigits } from '../../Types'; import Candidate from './Candidate'; type CandidatesProps = Readonly<{ @@ -13,7 +13,7 @@ type CandidatesProps = Readonly<{ * The classes added to each candidate (see {@link Cell#candidateClasses}) */ classes: string[] | null -}> & _ReactProps +}> type ThisHasCandidate = { hasCandidate (candidate: SudokuDigits): boolean } export function _content (self: ThisHasCandidate, candidate: SudokuDigits) { @@ -32,20 +32,9 @@ export function _content (self: ThisHasCandidate, candidate: SudokuDigits) { * TODO: Use grid since in this case data is not tabular */ export default class Candidates extends React.Component<CandidatesProps> { - repeatedProps: { readonly index: IndexToNine; readonly className: string; }[]; constructor(props: CandidatesProps) { super(props) this.hasCandidate = this.hasCandidate.bind(this) - this.repeatedProps = INDICES_TO_NINE.map(index => ({ - index, - className: this.props.classes?.[index] ?? '' - } as const)) - } - - - /** How many candidates are left */ - get numCandidates(): number { - return this.props.data.length } hasCandidate (candidate: SudokuDigits): boolean { @@ -57,22 +46,29 @@ export default class Candidates extends React.Component<CandidatesProps> { <table className="Candidates"> <tbody> <tr> - <Candidate {...this.repeatedProps[0]}>{_content(this, 1)}</Candidate> - <Candidate {...this.repeatedProps[1]}>{_content(this, 2)}</Candidate> - <Candidate {...this.repeatedProps[2]}>{_content(this, 3)}</Candidate> + <Candidate {...this.repeatedProps(0)}>{_content(this, 1)}</Candidate> + <Candidate {...this.repeatedProps(1)}>{_content(this, 2)}</Candidate> + <Candidate {...this.repeatedProps(2)}>{_content(this, 3)}</Candidate> </tr> <tr> - <Candidate {...this.repeatedProps[3]}>{_content(this, 4)}</Candidate> - <Candidate {...this.repeatedProps[4]}>{_content(this, 5)}</Candidate> - <Candidate {...this.repeatedProps[5]}>{_content(this, 6)}</Candidate> + <Candidate {...this.repeatedProps(3)}>{_content(this, 4)}</Candidate> + <Candidate {...this.repeatedProps(4)}>{_content(this, 5)}</Candidate> + <Candidate {...this.repeatedProps(5)}>{_content(this, 6)}</Candidate> </tr> <tr> - <Candidate {...this.repeatedProps[6]}>{_content(this, 7)}</Candidate> - <Candidate {...this.repeatedProps[7]}>{_content(this, 8)}</Candidate> - <Candidate {...this.repeatedProps[8]}>{_content(this, 9)}</Candidate> + <Candidate {...this.repeatedProps(6)}>{_content(this, 7)}</Candidate> + <Candidate {...this.repeatedProps(7)}>{_content(this, 8)}</Candidate> + <Candidate {...this.repeatedProps(8)}>{_content(this, 9)}</Candidate> </tr> </tbody> </table> ) } + + repeatedProps(index: IndexToNine) { + return { + index, + className: this.props.classes?.[index] ?? '' + } as const + } } diff --git a/src/Elems/MainElems/CandidatesDiff.tsx b/src/Elems/MainElems/CandidatesDiff.tsx index 936c9b34..2fbe5c07 100644 --- a/src/Elems/MainElems/CandidatesDiff.tsx +++ b/src/Elems/MainElems/CandidatesDiff.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { SudokuDigits, _ReactProps } from '../../Types'; +import { SudokuDigits } from '../../Types'; import Candidate from './Candidate'; import { _content } from './Candidates'; @@ -8,11 +8,11 @@ type CandidatesDiffProps = Readonly<{ previous: SudokuDigits[] | null current: SudokuDigits[] classes: string[] | null -}> & _ReactProps +}> /** - * Based off of `Candidates`, - * This adds css classes to show if one or more candidates were added or removed + * Based on `Candidates`, + * This adds css classes for any removed or added candidates. * * The css classes actually have precedence over the classes defined by strategies */ @@ -24,12 +24,6 @@ export default class CandidatesDiff extends React.Component<CandidatesDiffProps> this.hasCandidate = this.hasCandidate.bind(this) } - - /** How many candidates are left */ - get numCandidates(): number { - return this.props.current.length - } - hadCandidate(candidate: SudokuDigits): boolean { if (this.props.previous === null) { return this.props.current.includes(candidate) @@ -65,12 +59,12 @@ export default class CandidatesDiff extends React.Component<CandidatesDiffProps> ) } + /** Adds a css class for any new or deleted candidates */ _color (candidate: SudokuDigits) { if (this.hasCandidate(candidate) === this.hadCandidate(candidate)) { - return this.props.classes?.[candidate] ?? "" + return this.props.classes?.[candidate - 1] ?? "" } - // Candidate has just been added or removed! return ( this.hasCandidate(candidate) ? "added" diff --git a/src/Elems/MainElems/Cell.tsx b/src/Elems/MainElems/Cell.tsx index f74396b2..6e88e540 100644 --- a/src/Elems/MainElems/Cell.tsx +++ b/src/Elems/MainElems/Cell.tsx @@ -3,6 +3,7 @@ import React, { Suspense } from 'react'; import { algebraic } from '../../Api/Utils'; import { IndexToNine, Mutable, SudokuDigits, ZeroToNine, _Callback } from '../../Types'; +import { arraysAreEqual } from '../../utils'; const Candidates = React.lazy(() => import('./Candidates')); const CandidatesDiff = React.lazy(() => import('./CandidatesDiff')); @@ -20,34 +21,30 @@ export type BaseCellProps = Readonly<{ type CellProps = BaseCellProps +type _UserDisplayState = + { active: false; pretend: false } | + { active: true; pretend: boolean } + +type _TrackCandidateState = + { + explaining: true + previousCandidates: SudokuDigits[] + classes: null | string[] + candidateClasses: null | (Record<IndexToNine, string> & string[]) + } | + { + explaining: false + previousCandidates: null + classes: null + candidateClasses: null + } + type CellState = Readonly<( { candidates: SudokuDigits[] showCandidates: boolean error: boolean - } & ( - { - active: false - pretend: false - } | - { - active: true - pretend: boolean - } - ) & ( - { - explaining: true - previousCandidates: null | SudokuDigits[] - classes: null | string[] - candidateClasses: null | Record<IndexToNine, string> - } | - { - explaining: false - previousCandidates: null - classes: null - candidateClasses: null - } - ) + } & _UserDisplayState & _TrackCandidateState )> @@ -74,19 +71,25 @@ export default class Cell extends React.Component<CellProps, CellState> { this.state = { /** - * `explaining` is turned to true at the beginning of each strategy Step + * !misnomer + * Candidate changes when explaining are tracked (see previousCandidates) + * Set to false then true when starting a strategy. + * Set to false if the strategy fails + * Set to false on clear and undo */ explaining: false, /** * When `explaining` is true, the user can undo * This stores the previousCandidates, if changed + * Used by CandidatesDiff to display eliminated / added candidates. */ previousCandidates: null, - /** An array of possible candidates. - * Starts at [1, 2, 3, 4, 5, 6, 7, 8, 9] and updates in `whenKeyDown` - */ + /** + * The candidates, aka the possible digits of a cell + * Updates in `whenKeyDown` or by strategies. + */ candidates: [1, 2, 3, 4, 5, 6, 7, 8, 9], /** @@ -96,16 +99,13 @@ export default class Cell extends React.Component<CellProps, CellState> { /** * Used for styling - for example highlighting a candidate red - * - * Although the type is `string[] | null`, it's really - * `{ [key: IndexToNine]: string } | null` */ candidateClasses: null, /** Whether to show candidates * * This has no effect when there's 0 or 1 candidates - - * in those cases only a single digit is shown: 0 || 1 to 9 + * in those cases only a single digit is shown: 0 | 1 to 9 * * `whenFocus` sets this to true * `whenBlur` sets this to true when 1 < candidates < 9 @@ -123,11 +123,9 @@ export default class Cell extends React.Component<CellProps, CellState> { * This boolean controls pretending. * * When an unbothered cell is focused, we pretend there are no candidates. - * This makes it easier to fill in a sudoku + * This makes it _so_ much easier to fill in digits * * Unbothered: showCandidates===false && numCandidates===9 - * - * Very useful when you're just filling in digits */ pretend: false } @@ -163,16 +161,13 @@ export default class Cell extends React.Component<CellProps, CellState> { setCandidatesTo(candidates: SudokuDigits[], callback?: () => void) { this.setState((prevState: CellState) => { // Same candidates - if (prevState.candidates.sort().join('') === candidates.sort().join('')) { + if (arraysAreEqual(prevState.candidates.sort(), candidates.sort())) { return prevState } const newState = { candidates } as Mutable<CellState> - // Only change previousCandidates if they don't exist - if (prevState.explaining && prevState.previousCandidates === null) { - newState.previousCandidates = prevState.candidates - } + // Note: Edits can be undone by, well, undoing and setting back to previousCandidates if (candidates.length === 0) { newState.showCandidates = false @@ -220,9 +215,9 @@ export default class Cell extends React.Component<CellProps, CellState> { * (new Cell()).highlight([2, 3, 4], 'blue') // Adds the class "blue" to the Candidates 2, 3, and 4 * * @param candidates - * @param color - This is really a css class, so remember to check `Candidate.css` + * @param colorClass - This is a css class, so check/update `Candidate.css` */ - highlight(candidates: SudokuDigits[], color: string) { + highlight(candidates: SudokuDigits[], colorClass: string) { this.setState((state: CellState) => { if (state.explaining === false) { return null @@ -231,10 +226,10 @@ export default class Cell extends React.Component<CellProps, CellState> { // Array of 9 const newCandidateClasses = state.candidateClasses ?? ['', '', '', '', '', '', '', '', ''] - const hasColorAlready = new RegExp(` ${color}( |$)`) + const hasColorAlready = new RegExp(` ${colorClass}( |$)`) for (const candidate of candidates) { if (!hasColorAlready.test(newCandidateClasses[candidate - 1 as IndexToNine])) { - newCandidateClasses[candidate - 1 as IndexToNine] += ` ${color}` + newCandidateClasses[candidate - 1 as IndexToNine] += ` ${colorClass}` } } @@ -259,9 +254,10 @@ export default class Cell extends React.Component<CellProps, CellState> { } setExplainingToTrue (callback?: _Callback) { - this.setState({ + this.setState(state => ({ explaining: true, - }, callback) + previousCandidates: state.previousCandidates ?? state.candidates, + }), callback) } setExplainingToFalse (callback?: _Callback) { @@ -284,11 +280,11 @@ export default class Cell extends React.Component<CellProps, CellState> { render() { let content = <></>; - const loading = <span className="small">loading</span> + const loading = <span className="Loading">loading</span> // Using a span for single digits // so that I can force cells to always be [css height: 1/9th] - if (this.state.explaining && this.state.previousCandidates !== null) { + if (this.state.explaining && !arraysAreEqual(this.state.previousCandidates.sort(), this.state.candidates.sort())) { content = ( <Suspense fallback={loading}> <CandidatesDiff previous={this.state.previousCandidates} current={this.state.candidates} classes={this.state.candidateClasses} /> diff --git a/src/Elems/MainElems/Row.tsx b/src/Elems/MainElems/Row.tsx index 6dcad98b..df5d75a1 100644 --- a/src/Elems/MainElems/Row.tsx +++ b/src/Elems/MainElems/Row.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { IndexToNine, _ReactProps } from '../../Types'; +import { IndexToNine } from '../../Types'; import Cell, { BaseCellProps } from './Cell'; @@ -8,7 +8,7 @@ import Cell, { BaseCellProps } from './Cell'; type RowProps = Readonly<{ index: IndexToNine propsPassedDown: Omit<BaseCellProps, "row" | "column"> -}> & _ReactProps +}> /** * A row in a sudoku diff --git a/src/Elems/NoticeElems/NoticeWindow.tsx b/src/Elems/NoticeElems/NoticeWindow.tsx index b4bddffb..04112bc4 100644 --- a/src/Elems/NoticeElems/NoticeWindow.tsx +++ b/src/Elems/NoticeElems/NoticeWindow.tsx @@ -6,14 +6,14 @@ */ import React, { lazy, Suspense } from 'react' -import { NoticeInfo, NoticeType, _ReactProps } from '../../Types'; +import { NoticeInfo, NoticeType } from '../../Types'; const AlertNotice = lazy(() => import('./AlertNotice')); const PromptWindow = lazy(() => import('./PromptWindow')); type NoticeProps = Readonly<{ todo: NoticeInfo[] whenFinish: () => void -}> & _ReactProps +}> /** * A general component which either renders nothing, an {@link AlertNotice}, @@ -44,8 +44,7 @@ export default class Notice extends React.Component<NoticeProps> { ) default: console.error(nextTodo) - // @ts-expect-error TypeScript is now sure that nextTodo can't be anything. So `.type` doesn't exist, right? - throw new TypeError(`unknown todo type: ${String(nextTodo.type)}`) + throw new TypeError(`unknown todo type`) } } } diff --git a/src/Elems/Version.tsx b/src/Elems/Version.tsx index d11adbed..96ef6668 100644 --- a/src/Elems/Version.tsx +++ b/src/Elems/Version.tsx @@ -12,6 +12,6 @@ import StaticComponent from './StaticComponent'; */ export default class Version extends StaticComponent { render() { - return <span className="Version">v0.26.4</span> + return <span className="Version">v0.26.5</span> } } diff --git a/src/ErrorNotice.css b/src/ErrorNotice.css new file mode 100644 index 00000000..5730f7cd --- /dev/null +++ b/src/ErrorNotice.css @@ -0,0 +1,9 @@ + +.ErrorNotice { + font-family: var(--monospace); + white-space: pre; + padding: 1.5rem; + border: 3px solid red; + height: fit-content; + width: fit-content; +} diff --git a/src/ErrorNotice.tsx b/src/ErrorNotice.tsx new file mode 100644 index 00000000..ebf09f19 --- /dev/null +++ b/src/ErrorNotice.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import "./ErrorNotice.css"; + +type ErrorNoticeProps = { + children?: React.ReactNode +} + +type ErrorNoticeState = Readonly<{ + error: Error + errorInfo: React.ErrorInfo +} | { + error: undefined + errorInfo: undefined +}> + +export default class ErrorNotice extends React.Component<ErrorNoticeProps, ErrorNoticeState> { + constructor (props: ErrorNoticeProps) { + super(props) + this.state = { + error: undefined, + errorInfo: undefined, + } + } + + render() { + if (this.state.error) { + return <p className="ErrorNotice"> + Something went wrong <br /> + Please post this error on github: <br /> + {this.state.error.name}: {this.state.error.message}<br /> + {this.state.errorInfo.componentStack ?? '<No stack trace>'} + </p> + } + return this.props.children + } + + componentDidCatch (error: Error, errorInfo: React.ErrorInfo) { + console.error("App crashed", { error, errorInfo }); + this.setState({ error, errorInfo }) + } +} diff --git a/src/index.tsx b/src/index.tsx index 148c8956..85e5cf6d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,7 @@ import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import deprecate from './deprecate'; +import ErrorNotice from './ErrorNotice'; window.alert = deprecate(window.alert, "Use window._custom.alert instead") window.prompt = deprecate(window.prompt, "Use window._custom.prompt instead") @@ -11,7 +12,9 @@ window.prompt = deprecate(window.prompt, "Use window._custom.prompt instead") // Render the app ReactDOM.render( <React.StrictMode> - <App /> + <ErrorNotice> + <App /> + </ErrorNotice> </React.StrictMode>, document.getElementById('root') ); diff --git a/src/utils.test.ts b/src/utils.test.ts index 09a168b5..0abc3fcc 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,5 +1,5 @@ import React from "react" -import { convertArrayToEnglishList } from "./utils" +import { arraysAreEqual, convertArrayToEnglishList } from "./utils" test("convertArrayToEnglishList", () => { expect(() => convertArrayToEnglishList([])).toThrow(TypeError) @@ -9,3 +9,10 @@ test("convertArrayToEnglishList", () => { expect(convertArrayToEnglishList([1, 2, 3, 4])).toBe("1, 2, 3, and 4") expect(convertArrayToEnglishList("ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""))).toBe("A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, and Z") }) + +test("arraysAreEqual", () => { + expect(arraysAreEqual([], [2])).toBe(false) + expect(arraysAreEqual([2], [2])).toBe(true) + expect(arraysAreEqual([2], [2, 2])).toBe(false) + expect(arraysAreEqual([2], ["2"])).toBe(false) +}) diff --git a/src/utils.ts b/src/utils.ts index 637094b6..61667568 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,7 +3,11 @@ */ /** - * This function is used to wait for the components to update + * @example + * await forComponentsToUpdate() + * + * @description + * Do you want to wait for your components to update? Wow, look at this! * * This function simply returns `Promise<undefined>`. * When you await for that promise, the promise is added to the event stack: @@ -11,8 +15,7 @@ * 1. `setState` handlers * 2. `Promise<undefined>` * - * So by the time the promise has been awaited, - * the components have been updated. + * So by waiting for the promise to resolve, you wait for the components to update! * * Thanks to https://stackoverflow.com/q/47019199/12174015 * and the answer at https://stackoverflow.com/a/47022453/12174015 @@ -65,7 +68,8 @@ export async function forComponentsToUpdate (): Promise<undefined> { * convertArrayToEnglishList("ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("")) * * @description - * This only expects a `string | number`, so this won't happen anyways. + * This function expects a `string | number`, so DON'T DO THIS! + * Just some interesting edge cases. * * @example * // "2 and undefined" @@ -86,3 +90,11 @@ export function convertArrayToEnglishList<T extends string | number>(array: T[]) return `${array.slice(0, -1).join(', ')}, and ${array[array.length - 1]}` as const } } + +export function arraysAreEqual(array1: unknown[], array2: unknown[]): boolean { + if (array1.length !== array2.length) { + return false + } + + return array1.every((value, index) => array2[index] === value) +} diff --git a/yarn.lock b/yarn.lock index 4ab9f2d9..99478e6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2414,10 +2414,10 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^16.11.1": - version: 16.11.1 - resolution: "@types/node@npm:16.11.1" - checksum: 22cea470b89292810733b8b9fc1b6a1873bc3ed4d4cdf2f25e777dc607994bd7c12e64166d1a66dd5924734192b0a891b6aeb6813aa935f731345f4086234375 +"@types/node@npm:^16.11.4": + version: 16.11.4 + resolution: "@types/node@npm:16.11.4" + checksum: 96e08c0f8bb17601c18640a8eac61e2f236fe0c8a6b01a9221c10749157358fb29fdef4b5e264f72559e72f9830e6093f3ab5fef2df90afdad562ffa45ac534c languageName: node linkType: hard @@ -2456,12 +2456,12 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^17.0.9": - version: 17.0.9 - resolution: "@types/react-dom@npm:17.0.9" +"@types/react-dom@npm:^17.0.10": + version: 17.0.10 + resolution: "@types/react-dom@npm:17.0.10" dependencies: "@types/react": "*" - checksum: b7e898e1a22643a371f58e801a3d1d8cf13a82d77063c24be73e840ef8d877ca1d04adc5db168d0dac3167dc050a26b1d70efc5fe8566a7f46a3c488a8322989 + checksum: cc7d8d5b77ee2f3b989c107abd8ec0f2460ba1b1ee6e6d637124e1939594b5619fa4166ef0ea7632a69b68358e0b71aa618de446ccc416dcc0017175549da601 languageName: node linkType: hard @@ -2476,14 +2476,14 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^17.0.30": - version: 17.0.30 - resolution: "@types/react@npm:17.0.30" +"@types/react@npm:^17.0.32": + version: 17.0.32 + resolution: "@types/react@npm:17.0.32" dependencies: "@types/prop-types": "*" "@types/scheduler": "*" csstype: ^3.0.2 - checksum: e3aaac1b8fda6e3622b75db0bd7d8dc412c2f2b77a00afdd32cae8c71fb0b1ca6926ab1fbe1c536dd51d96c0ba372738993837a8df1637637aaab7b86e421b7f + checksum: 952d33bf948b6f4ce52fbf4118d9c99c97427aa3270a6bcde9f1156bb3898f1662d8f6ca128d095b4677acaff3971b3f761940dbb437366f656ad584eb69b108 languageName: node linkType: hard @@ -2967,6 +2967,13 @@ __metadata: languageName: node linkType: hard +"adverb-where@npm:^0.2.2": + version: 0.2.5 + resolution: "adverb-where@npm:0.2.5" + checksum: 8ad0f125717a4b86004c17a945ff0c9cac03bda00f0adea0d881638a421c7c937e92a65045cfaf0cbb7be2a2c1cb4c17e931bf67391261ca6aa35d0bf46e48b4 + languageName: node + linkType: hard + "agent-base@npm:6, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -4034,7 +4041,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^2.20.0": +"commander@npm:^2.19.0, commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3" checksum: ab8c07884e42c3a8dbc5dd9592c606176c7eb5c1ca5ff274bcf907039b2c41de3626f684ea75ccf4d361ba004bbaff1f577d5384c155f3871e456bdf27becf9e @@ -4934,6 +4941,13 @@ __metadata: languageName: node linkType: hard +"e-prime@npm:^0.10.4": + version: 0.10.4 + resolution: "e-prime@npm:0.10.4" + checksum: 4932a58f481c266e1e4d85a9d2c2b7ff797640429c71acda264379074a347bdc5e26497d32f96908c7b7760834c9e50e009ea97ab6a796dfa0248f38a6dc9c18 + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -5435,6 +5449,16 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-write-good-comments@npm:^0.1.3": + version: 0.1.3 + resolution: "eslint-plugin-write-good-comments@npm:0.1.3" + dependencies: + requireindex: ~1.1.0 + write-good: ^1.0.8 + checksum: 7f32f6942cc73ddc0374d8a69de50907a29aee217dd011afccd0946ac922131604c0ff36ccf48b3e389205c8b6ee4551936ca13c6ad363d54a350fc8a9775b9c + languageName: node + linkType: hard + "eslint-scope@npm:5.1.1, eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1": version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" @@ -8471,6 +8495,18 @@ __metadata: languageName: node linkType: hard +"no-cliches@npm:^0.3.0": + version: 0.3.4 + resolution: "no-cliches@npm:0.3.4" + peerDependencies: + eslint-plugin-import: ^2.22.1 + eslint-plugin-jsx-a11y: ^6.4.1 + eslint-plugin-react: ^7.21.5 + eslint-plugin-react-hooks: ^4.0.0 + checksum: 7abc57c9bc987453d4b8b606097b8ddffefec573782fea1c921784b6b51fdb247c9599bb73b38c7e1274f677aa517f2758155acf0585e5c58b77bedfe7e01861 + languageName: node + linkType: hard + "node-forge@npm:^0.10.0": version: 0.10.0 resolution: "node-forge@npm:0.10.0" @@ -9005,6 +9041,13 @@ __metadata: languageName: node linkType: hard +"passive-voice@npm:^0.1.0": + version: 0.1.0 + resolution: "passive-voice@npm:0.1.0" + checksum: 4f5d1810e609342f97ece91801388e9751f6185094e4c598a902b895fae303a82c8d14472e8a57ba9161c1cc2c275189ad59c83138c33bcfaf0c7562c63309a7 + languageName: node + linkType: hard + "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -10548,6 +10591,13 @@ __metadata: languageName: node linkType: hard +"requireindex@npm:~1.1.0": + version: 1.1.0 + resolution: "requireindex@npm:1.1.0" + checksum: 397057d97d7f753a3851abf0d6db94c295bd8254536f71f622b896ba08ea8c0d3e3771c8b009a557e6ce602f4245c0588836cdf59c4ce588fff721a7b855d323 + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -11103,14 +11153,15 @@ resolve@^2.0.0-next.3: "@testing-library/react": ^12.1.2 "@testing-library/user-event": ^13.5.0 "@types/jest": ^27.0.2 - "@types/node": ^16.11.1 - "@types/react": ^17.0.30 - "@types/react-dom": ^17.0.9 + "@types/node": ^16.11.4 + "@types/react": ^17.0.32 + "@types/react-dom": ^17.0.10 eslint-plugin-jest-dom: ^3.9.2 eslint-plugin-react-perf: ^3.3.0 eslint-plugin-regexp: ^1.4.1 eslint-plugin-sonarjs: ^0.10.0 eslint-plugin-unicorn: ^37.0.1 + eslint-plugin-write-good-comments: ^0.1.3 react: ^17.0.2 react-dom: ^17.0.2 react-scripts: ^5.0.0-next.47 @@ -11850,6 +11901,13 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"too-wordy@npm:^0.3.1": + version: 0.3.4 + resolution: "too-wordy@npm:0.3.4" + checksum: c7e4b5cdaf445a4d7ea8d07e4f293e10507b6e70252b6a0a17d0e28b676b0d5c193913f6313ab293e661e72655a27821aa64a3b479d044a0a9bb3e69e0d0e85b + languageName: node + linkType: hard + "tough-cookie@npm:^4.0.0": version: 4.0.0 resolution: "tough-cookie@npm:4.0.0" @@ -12290,6 +12348,13 @@ typescript@^4.4.4: languageName: node linkType: hard +"weasel-words@npm:^0.1.1": + version: 0.1.1 + resolution: "weasel-words@npm:0.1.1" + checksum: a7693308cf29af979da3b9d4803b535c8ea0fa78e0fa3baedae44fa353b5c93779df76189d54829743fd6aae029e46cbb06e30dc4baa8b36032b0573157f8200 + languageName: node + linkType: hard + "web-vitals@npm:^2.1.2": version: 2.1.2 resolution: "web-vitals@npm:2.1.2" @@ -12800,6 +12865,24 @@ typescript@^4.4.4: languageName: node linkType: hard +"write-good@npm:^1.0.8": + version: 1.0.8 + resolution: "write-good@npm:1.0.8" + dependencies: + adverb-where: ^0.2.2 + commander: ^2.19.0 + e-prime: ^0.10.4 + no-cliches: ^0.3.0 + passive-voice: ^0.1.0 + too-wordy: ^0.3.1 + weasel-words: ^0.1.1 + bin: + write-good: bin/write-good.js + writegood: bin/write-good.js + checksum: 045f1d1b50f73d15c6664350ecacaa4bf74bab681376747b42c14ccaec06a667db89874ec0c1ff31a46e01f52051ed76518c2b920d114925f84aefcb91708e92 + languageName: node + linkType: hard + "ws@npm:^7.4.6": version: 7.5.5 resolution: "ws@npm:7.5.5"