diff --git a/doc/markdown.md b/doc/markdown.md index eb96d0bb..23880877 100644 --- a/doc/markdown.md +++ b/doc/markdown.md @@ -13,6 +13,8 @@ such as [quick reference](/doc/quickReference.md). `[R]` means required; `[O]` means optional. +**These syntaxes must not nest with general markdown syntaxes. + ### Text coloring ``` diff --git a/pages/[lang]/thanks.tsx b/pages/[lang]/thanks.tsx index 4bbba801..c2e644a0 100644 --- a/pages/[lang]/thanks.tsx +++ b/pages/[lang]/thanks.tsx @@ -17,24 +17,33 @@ const SpecialThanks = () => { </a> </h3> <ul> + <li> + 桜井みゆき + <Badge variant="primary">{t((t) => t.donation.tierSSS)}</Badge> + <Badge variant="orange">{t((t) => t.misc.omGroup)}</Badge> + </li> + <li> + Yu + <Badge variant="primary">{t((t) => t.donation.tierSSS)}</Badge> + </li> <li> Andy - <Badge variant="info">{t((t) => t.donation.tierS2)}</Badge> + <Badge variant="secondary">{t((t) => t.donation.tierS2)}</Badge> <Badge variant="orange">{t((t) => t.misc.omMember)}</Badge> </li> <li> Ellie - <Badge variant="info">{t((t) => t.donation.tierS2)}</Badge> + <Badge variant="secondary">{t((t) => t.donation.tierS2)}</Badge> <Badge variant="orange">{t((t) => t.misc.omMember)}</Badge> </li> <li> - Piglet / ピグレット - <Badge variant="info">{t((t) => t.donation.tierS1)}</Badge> - <Badge variant="orange">{t((t) => t.misc.omGroup)}</Badge> + 皮皮熊艹 + <Badge variant="secondary">{t((t) => t.donation.tierS2)}</Badge> + <Badge variant="orange">{t((t) => t.misc.omMember)}</Badge> </li> <li> - 皮皮熊艹 - <Badge variant="info">{t((t) => t.donation.tierS2)}</Badge> + Piglet / ピグレット + <Badge variant="info">{t((t) => t.donation.tierS1)}</Badge> <Badge variant="orange">{t((t) => t.misc.omMember)}</Badge> </li> <li> diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index f4a23893..00000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/logo192.png b/public/logo192.png index 2cfcb283..4ada50cd 100644 Binary files a/public/logo192.png and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png index c645f47f..959d78bf 100644 Binary files a/public/logo512.png and b/public/logo512.png differ diff --git a/public/manifest.json b/public/manifest.json index 38a3e139..e3cbc77d 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,11 +2,6 @@ "short_name": "龍絆攻略站 by OM", "name": "龍絆攻略站 by OM", "icons": [ - { - "src": "favicon.ico", - "sizes": "256x256 128x128 64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, { "src": "logo192.png", "type": "image/png", diff --git a/src/api-def b/src/api-def index 8923d4c4..ac49f64d 160000 --- a/src/api-def +++ b/src/api-def @@ -1 +1 @@ -Subproject commit 8923d4c4a6335c82a687e0c3dc22a2af5c9bf915 +Subproject commit ac49f64dc3a1e2231d2d3561c4acb01b794a9a95 diff --git a/src/components/elements/gameData/skillAtk/hooks/preset.test.ts b/src/components/elements/gameData/skillAtk/hooks/preset.test.ts index 40a16431..977065d7 100644 --- a/src/components/elements/gameData/skillAtk/hooks/preset.test.ts +++ b/src/components/elements/gameData/skillAtk/hooks/preset.test.ts @@ -113,35 +113,4 @@ describe('Input preset hook', () => { await waitFor(() => expect(result.current.getPresetStatus.fetched).toBeTruthy()); expect(gaEvent).toHaveBeenCalledTimes(1); }); - - it('does not have 2 preset IDs in the new link if created twice', async () => { - jest.spyOn(ApiRequestSender, 'getPresetAtkSkill').mockResolvedValue({ - code: ApiResponseCode.SUCCESS, - success: true, - preset: {a: true}, - }); - jest.spyOn(ApiRequestSender, 'setPresetAtkSkill').mockResolvedValue({ - code: ApiResponseCode.SUCCESS, - success: true, - presetId: 'preset2', - }); - // @ts-ignore - delete window.location; - // @ts-ignore - window.location = { - href: `http://localhost/?${PRESET_QUERY_NAME}=preset`, - }; - - const {result} = renderReactHook( - () => useAtkSkillInput(fnOnNotLoggedIn), - { - hasSession: true, - routerOptions: {query: {[PRESET_QUERY_NAME]: 'preset'}}, - }, - ); - - result.current.makePreset(); - await waitFor(() => expect(result.current.makePresetLink).not.toBeNull()); - expect(new URL(result.current.makePresetLink || '').searchParams.getAll(PRESET_QUERY_NAME).length).toBe(1); - }); }); diff --git a/src/components/elements/gameData/skillAtk/hooks/preset.ts b/src/components/elements/gameData/skillAtk/hooks/preset.ts index 79cd1913..c4860eed 100644 --- a/src/components/elements/gameData/skillAtk/hooks/preset.ts +++ b/src/components/elements/gameData/skillAtk/hooks/preset.ts @@ -4,7 +4,7 @@ import {AppReactContext} from '../../../../../context/app/main'; import {useNextRouter} from '../../../../../utils/router'; import {ApiRequestSender} from '../../../../../utils/services/api/requestSender'; import {GoogleAnalytics} from '../../../../../utils/services/ga'; -import {FetchStatus, FetchStatusSimple, isNotFetched} from '../../../common/fetch'; +import {FetchStatusSimple, isNotFetched} from '../../../common/fetch'; import {InputPanelCommonProps} from '../../../input/types'; import {InputData} from '../in/types'; import {generateInputData, overwriteInputData} from '../in/utils/inputData'; @@ -14,14 +14,12 @@ export const PRESET_QUERY_NAME = 'preset'; type UseAtkSkillInputReturn = InputPanelCommonProps<InputData> & { getPresetStatus: FetchStatusSimple, - makePresetLink: string | null, - makePreset: () => void, } // This input data is expect to change frequently. // Therefore, it should not be used in expensive component, such as ATK skill output, // because every change triggers a re-render. -export const useAtkSkillInput = (onNotLoggedIn?: () => void): UseAtkSkillInputReturn => { +export const useAtkSkillInput = (onNotLoggedIn: () => void): UseAtkSkillInputReturn => { const {query} = useNextRouter(); const presetId = query[PRESET_QUERY_NAME]; @@ -32,38 +30,6 @@ export const useAtkSkillInput = (onNotLoggedIn?: () => void): UseAtkSkillInputRe fetched: !presetId, fetching: false, }); - const [makePresetStatus, setMakePresetStatus] = React.useState<FetchStatus<string | null>>({ - fetched: false, - fetching: false, - data: null, - }); - - const onNotLoggedInInternal = () => { - if (onNotLoggedIn) { - onNotLoggedIn(); - } else { - console.error('User not logged in, action prohibited.'); - } - }; - - const makePreset = () => { - setMakePresetStatus({...makePresetStatus, fetched: false, fetching: true}); - if (context?.session) { - ApiRequestSender.setPresetAtkSkill(context.session.user.id.toString(), inputData) - .then((response) => { - const link = new URL(window.location.href); - link.searchParams.set(PRESET_QUERY_NAME, response.presetId); - - setMakePresetStatus({fetched: true, fetching: false, data: link.href}); - }) - .catch(() => { - setMakePresetStatus({fetched: true, fetching: false, data: null}); - }); - } else { - onNotLoggedInInternal(); - setMakePresetStatus({...makePresetStatus, fetched: true, fetching: false}); - } - }; if (isNotFetched(getPresetStatus)) { if (context?.session) { @@ -79,15 +45,9 @@ export const useAtkSkillInput = (onNotLoggedIn?: () => void): UseAtkSkillInputRe GoogleAnalytics.presetLoaded('atkSkill'); } else { setGetPresetStatus({...getPresetStatus, fetched: true, fetching: false}); - onNotLoggedInInternal(); + onNotLoggedIn(); } } - return { - inputData, - setInputData, - getPresetStatus: getPresetStatus, - makePresetLink: makePresetStatus.data, - makePreset, - }; + return {inputData, setInputData, getPresetStatus}; }; diff --git a/src/components/elements/gameData/skillAtk/in/limit.tsx b/src/components/elements/gameData/skillAtk/in/display.tsx similarity index 100% rename from src/components/elements/gameData/skillAtk/in/limit.tsx rename to src/components/elements/gameData/skillAtk/in/display.tsx diff --git a/src/components/elements/gameData/skillAtk/in/main.tsx b/src/components/elements/gameData/skillAtk/in/main.tsx index 1e40e539..633b1bc1 100644 --- a/src/components/elements/gameData/skillAtk/in/main.tsx +++ b/src/components/elements/gameData/skillAtk/in/main.tsx @@ -9,8 +9,8 @@ import {ResourceLoader} from '../../../../../utils/services/resources/loader'; import {useFetchState} from '../../../common/fetch'; import {CommonModal, ModalState} from '../../../common/modal'; import {useAtkSkillInput} from '../hooks/preset'; +import {DisplayItemPicker} from './display'; import {Filter} from './filter'; -import {DisplayItemPicker} from './limit'; import {InputParameters} from './params'; import {InputData} from './types'; import {validateInputData} from './utils/inputData'; @@ -30,9 +30,13 @@ export const AttackingSkillInput = ({isAllFetched, onSearchRequested}: InputProp title: '', message: '', }); - const {inputData, setInputData, getPresetStatus} = useAtkSkillInput(); - const isSearchAllowed = isAllFetched && getPresetStatus.fetched; - + const {inputData, setInputData, getPresetStatus} = useAtkSkillInput(() => { + setModalState({ + title: 'Error', + show: true, + message: t((t) => t.game.skillAtk.error.presetMustLogin), + }); + }); const {fetchStatus: conditionEnums, fetchFunction: fetchConditionEnums} = useFetchState<CategorizedConditionEnums>( {afflictions: [], elements: []}, ResourceLoader.getEnumCategorizedConditions, @@ -44,6 +48,8 @@ export const AttackingSkillInput = ({isAllFetched, onSearchRequested}: InputProp 'Failed to fetch the element enums.', ); + const isSearchAllowed = isAllFetched && getPresetStatus.fetched; + fetchConditionEnums(); fetchElemEnums(); diff --git a/src/components/elements/gameData/skillAtk/in/utils/calculate.ts b/src/components/elements/gameData/skillAtk/in/utils/calculate.ts index df77ba7f..a6c0e5e0 100644 --- a/src/components/elements/gameData/skillAtk/in/utils/calculate.ts +++ b/src/components/elements/gameData/skillAtk/in/utils/calculate.ts @@ -1,7 +1,7 @@ import {ElementBonusData} from '../../../../../../api-def/resources'; import {UseFetchEnumsReturn} from '../../hooks/enums'; import {CalculatedSkillEntry} from '../../out/types'; -import {calculateEntries, filterSkillEntries} from '../../out/utils'; +import {calculateEntries, filterSkillEntries} from '../../out/utils/entries'; import {InputData} from '../types'; diff --git a/src/components/elements/gameData/skillAtk/main.test.tsx b/src/components/elements/gameData/skillAtk/main.test.tsx index e722aaaa..186049e7 100644 --- a/src/components/elements/gameData/skillAtk/main.test.tsx +++ b/src/components/elements/gameData/skillAtk/main.test.tsx @@ -319,10 +319,75 @@ describe('ATK skill lookup', () => { userEvent.click(searchButton); await waitForEntryProcessed(); - screen.debug(undefined, 100000); // Actual damage from Wedding Aoi expect(await screen.findByText('417,497')).toBeInTheDocument(); }); - it.todo('reset preset status on input changed then re-search'); + it('makes correct input preset', async () => { + const fnMakePreset = jest.spyOn(ApiRequestSender, 'setPresetAtkSkill').mockResolvedValue({ + code: ApiResponseCode.SUCCESS, + success: true, + presetId: 'abc', + }); + + renderReact( + () => <AttackingSkillLookup/>, + {hasSession: true}, + ); + + const displayActualDamageBtn = await screen.findByText(translationEN.game.skillAtk.display.options.actualDamage); + userEvent.click(displayActualDamageBtn); + await waitFor(() => expect(displayActualDamageBtn.parentNode).toHaveClass('active')); + + const searchButton = await screen.findByText( + translationEN.misc.search, + {selector: 'button:enabled'}, + {timeout: 2000}, + ); + userEvent.click(searchButton); + + await waitForEntryProcessed(); + + const shareButton = screen.getByText('', {selector: 'i.bi-share-fill'}); + userEvent.click(shareButton); + + expect(fnMakePreset.mock.calls[0][1].display.actualDamage).toBe(true); + }); + + it('reset preset status on input changed then re-search', async () => { + jest.spyOn(ApiRequestSender, 'setPresetAtkSkill').mockResolvedValue({ + code: ApiResponseCode.SUCCESS, + success: true, + presetId: 'abc', + }); + + renderReact( + () => <AttackingSkillLookup/>, + {hasSession: true}, + ); + + const displayActualDamageBtn = await screen.findByText(translationEN.game.skillAtk.display.options.actualDamage); + userEvent.click(displayActualDamageBtn); + await waitFor(() => expect(displayActualDamageBtn.parentNode).toHaveClass('active')); + + const searchButton = await screen.findByText( + translationEN.misc.search, + {selector: 'button:enabled'}, + {timeout: 2000}, + ); + userEvent.click(searchButton); + + await waitForEntryProcessed(); + + const shareButton = screen.getByText('', {selector: 'i.bi-share-fill'}); + userEvent.click(shareButton); + expect(shareButton).not.toBeInTheDocument(); + + userEvent.click(displayActualDamageBtn); + await waitFor(() => expect(displayActualDamageBtn.parentNode).not.toHaveClass('active')); + + userEvent.click(searchButton); + + expect(screen.getByText('', {selector: 'i.bi-share-fill'})).toBeInTheDocument(); + }); }); diff --git a/src/components/elements/gameData/skillAtk/main.tsx b/src/components/elements/gameData/skillAtk/main.tsx index 34cd3948..bc792277 100644 --- a/src/components/elements/gameData/skillAtk/main.tsx +++ b/src/components/elements/gameData/skillAtk/main.tsx @@ -6,7 +6,6 @@ import Row from 'react-bootstrap/Row'; import {scrollRefToTop} from '../../../../utils/scroll'; import {GoogleAnalytics} from '../../../../utils/services/ga'; -import {CommonModal, ModalState} from '../../common/modal'; import {useFetchEnums} from './hooks/enums'; import {AttackingSkillInput} from './in/main'; import {InputData} from './in/types'; @@ -18,16 +17,17 @@ import {AttackingSkillPreset} from './preset/main'; import {AttackingSkillSorter} from './sorter/main'; +type State = { + inputData: InputData, + calculatedEntries?: Array<CalculatedSkillEntry>, +} + export const AttackingSkillLookup = () => { // Having this reduces state updates when changing input. // Frequent update in this component is not ideal because rendering output is expensive. - const [inputDataForward, setInputDataForward] = React.useState(generateInputData()); - const [modalState, setModalState] = React.useState<ModalState>({ - show: false, - title: '', - message: '', + const [inputDataForward, setInputDataForward] = React.useState<State>({ + inputData: generateInputData(), }); - const [calculatedEntries, setCalculatedEntries] = React.useState<Array<CalculatedSkillEntry> | undefined>(); const entryCol = React.useRef<HTMLDivElement>(null); @@ -42,15 +42,14 @@ export const AttackingSkillLookup = () => { } = useFetchEnums(); React.useEffect(() => { - if (!calculatedEntries) { + if (!inputDataForward.calculatedEntries) { return; } scrollRefToTop(entryCol); - }, [calculatedEntries]); + }, [inputDataForward.calculatedEntries]); return ( <> - <CommonModal modalState={modalState} setModalState={setModalState}/> <Row> <Col lg={4} className="rounded bg-black-32 p-3 mb-3"> <AttackingSkillInput @@ -58,27 +57,36 @@ export const AttackingSkillLookup = () => { onSearchRequested={(inputData: InputData) => { GoogleAnalytics.damageCalc('search', inputData); - setCalculatedEntries(getCalculatedEntries(inputData, attackingSkillEntries, elementBonuses)); - setInputDataForward(inputData); + setInputDataForward({ + inputData, + calculatedEntries: getCalculatedEntries(inputData, attackingSkillEntries, elementBonuses), + }); }} /> </Col> <Col ref={entryCol} lg={8} className="px-0 px-lg-3"> <Form.Row className="text-right mb-1"> <Col> - <AttackingSkillPreset isEnabled={!!calculatedEntries?.length}/> + <AttackingSkillPreset + inputData={inputDataForward.inputData} + isEnabled={!!inputDataForward.calculatedEntries?.length} + /> </Col> <Col xs="auto"> <AttackingSkillSorter + inputData={inputDataForward.inputData} onOrderPicked={(newInputData) => { - setCalculatedEntries(getCalculatedEntries(newInputData, attackingSkillEntries, elementBonuses)); + setInputDataForward({ + inputData: newInputData, + calculatedEntries: getCalculatedEntries(newInputData, attackingSkillEntries, elementBonuses), + }); }} /> </Col> </Form.Row> <AttackingSkillOutput - displayConfig={inputDataForward.display} - calculatedEntries={calculatedEntries || []} + displayConfig={inputDataForward.inputData.display} + calculatedEntries={inputDataForward.calculatedEntries || []} conditionEnumMap={conditionEnumMap} skillIdentifierInfo={skillIdentifierInfo} skillEnums={skillEnums} diff --git a/src/components/elements/gameData/skillAtk/out/sections/animation.tsx b/src/components/elements/gameData/skillAtk/out/sections/animation.tsx index 0f3a9fa1..8efcbb94 100644 --- a/src/components/elements/gameData/skillAtk/out/sections/animation.tsx +++ b/src/components/elements/gameData/skillAtk/out/sections/animation.tsx @@ -28,10 +28,12 @@ const HitTiming = ({atkSkillEntry}: SectionProps) => { <Collapse in={show}> <div className="mt-2"> <table> - <tr> - <th>#</th> - <th>{t((t) => t.game.skillAtk.animation.hitTimingHeader)}</th> - </tr> + <thead> + <tr> + <th>#</th> + <th>{t((t) => t.game.skillAtk.animation.hitTimingHeader)}</th> + </tr> + </thead> <tbody> { atkSkillEntry.skill.hitTimingSecMax.map((timing, idx) => ( @@ -76,14 +78,16 @@ const CancelAction = ({atkSkillEntry, skillEnums, conditionEnumMap}: SectionAnim <Collapse in={show}> <div className="mt-2"> <table> - <tr> - <th>{t((t) => t.game.skillAtk.animation.cancelHeader.action)}</th> - <th>{t((t) => t.game.skillAtk.animation.cancelHeader.time)}</th> - { - isAnyCancelHasPreconditions && - <th>{t((t) => t.game.skillAtk.animation.cancelHeader.preConditions)}</th> - } - </tr> + <thead> + <tr> + <th>{t((t) => t.game.skillAtk.animation.cancelHeader.action)}</th> + <th>{t((t) => t.game.skillAtk.animation.cancelHeader.time)}</th> + { + isAnyCancelHasPreconditions && + <th>{t((t) => t.game.skillAtk.animation.cancelHeader.preConditions)}</th> + } + </tr> + </thead> <tbody> { atkSkillEntry.skill.cancelActionsMax.map((cancelUnit, idx) => ( diff --git a/src/components/elements/gameData/skillAtk/out/sections/sp/efficiency.tsx b/src/components/elements/gameData/skillAtk/out/sections/sp/efficiency.tsx index bdfb8ed8..92380921 100644 --- a/src/components/elements/gameData/skillAtk/out/sections/sp/efficiency.tsx +++ b/src/components/elements/gameData/skillAtk/out/sections/sp/efficiency.tsx @@ -73,15 +73,25 @@ export const SpEfficiencyTable = ({calculatedData, statusEnums}: SectionSpInfoPr <td>{calculatedData.efficiency.modPctPer1KSsp.toFixed(2)}%</td> </tr> } + { + calculatedData.skillEntry.skill.spGradualPctMax > 0 && + <tr> + <td>{t((t) => t.game.skillAtk.spInfo.spPctPerSec)}</td> + <td>{calculatedData.skillEntry.skill.spGradualPctMax.toFixed(2)}%</td> + </tr> + } { !!calculatedData.skillEntry.skill.afflictions.length && <> - <tr> - <td>{t((t) => t.game.skillAtk.spInfo.efficiency.secPer1KSp)}</td> - <td> - <AfflictionDataCell statusEnums={statusEnums} data={calculatedData.efficiency.secPer1KSp}/> - </td> - </tr> + { + !calculatedData.skillEntry.skill.spGradualPctMax && + <tr> + <td>{t((t) => t.game.skillAtk.spInfo.efficiency.secPer1KSp)}</td> + <td> + <AfflictionDataCell statusEnums={statusEnums} data={calculatedData.efficiency.secPer1KSp}/> + </td> + </tr> + } { calculatedData.skillEntry.skill.sharable && <tr> diff --git a/src/components/elements/gameData/skillAtk/out/sections/sp/info.tsx b/src/components/elements/gameData/skillAtk/out/sections/sp/info.tsx index 82541a1a..53b3e4fc 100644 --- a/src/components/elements/gameData/skillAtk/out/sections/sp/info.tsx +++ b/src/components/elements/gameData/skillAtk/out/sections/sp/info.tsx @@ -8,6 +8,8 @@ import {SectionSpInfoProps} from './main'; export const SpInfoTable = ({calculatedData}: Pick<SectionSpInfoProps, 'calculatedData'>) => { const {t} = useI18n(); + const sp = calculatedData.skillEntry.skill.spMax.toFixed(0); + return ( <div className="mt-2"> <table> @@ -18,7 +20,14 @@ export const SpInfoTable = ({calculatedData}: Pick<SectionSpInfoProps, 'calculat <td className={styles.ssCost}>{t((t) => t.game.skillAtk.spInfo.ssCost)}</td> </tr> <tr> - <td className={styles.sp}>{calculatedData.skillEntry.skill.spMax.toFixed(0)}</td> + <td className={styles.sp}>{ + calculatedData.skillEntry.skill.spGradualPctMax ? + t( + (t) => t.game.skillAtk.spInfo.spGradualFill, + {secs: calculatedData.efficiency.spFullFillSec.toFixed(1), sp}, + ) : + sp + }</td> <td className={styles.ssp}>{ calculatedData.skillEntry.skill.sharable ? calculatedData.skillEntry.skill.ssSp.toFixed(0) : diff --git a/src/components/elements/gameData/skillAtk/out/sections/sp/main.test.tsx b/src/components/elements/gameData/skillAtk/out/sections/sp/main.test.tsx index 90751b5c..a20a5422 100644 --- a/src/components/elements/gameData/skillAtk/out/sections/sp/main.test.tsx +++ b/src/components/elements/gameData/skillAtk/out/sections/sp/main.test.tsx @@ -193,4 +193,115 @@ describe('SP info section', () => { expect(screen.getByText(translationEN.game.skillAtk.spInfo.efficiency.secPer1KSp)).toBeInTheDocument(); expect(screen.getByText(translationEN.game.skillAtk.spInfo.efficiency.secPer1KSsp)).toBeInTheDocument(); }); + + it('hides affliction efficiency if the skill gradually fills', async () => { + const calculatedData: CalculatedSkillEntry = { + ...calculatedDataTemplate, + skillEntry: { + ...calculatedDataTemplate.skillEntry, + skill: { + ...calculatedDataTemplate.skillEntry.skill, + spGradualPctMax: 2.5, + }, + }, + efficiency: { + ...calculatedDataTemplate.efficiency, + spFullFillSec: 40, + }, + }; + + renderReact(() => ( + <SectionSpInfo + calculatedData={calculatedData} + statusEnums={statusEnums} + /> + )); + + const efficiencyButton = screen.getByText( + translationEN.game.skillAtk.spInfo.efficiencyIndexes, + {selector: 'button'}, + ); + userEvent.click(efficiencyButton); + + await waitFor(() => expect(screen.getByText( + translationEN.game.skillAtk.spInfo.efficiency.modPctPer1KSp, + {selector: '.collapse.show *'}, + )).toBeInTheDocument()); + expect(screen.queryByText(translationEN.game.skillAtk.spInfo.efficiency.secPer1KSp)).not.toBeInTheDocument(); + }); + + it('shows SP % per second if the skill gradually fills', async () => { + const calculatedData: CalculatedSkillEntry = { + ...calculatedDataTemplate, + skillEntry: { + ...calculatedDataTemplate.skillEntry, + skill: { + ...calculatedDataTemplate.skillEntry.skill, + spMax: 8000, + spGradualPctMax: 2.5, + }, + }, + efficiency: { + ...calculatedDataTemplate.efficiency, + spFullFillSec: 40, + }, + }; + + renderReact(() => ( + <SectionSpInfo + calculatedData={calculatedData} + statusEnums={statusEnums} + /> + )); + expect(screen.getByText('40.0 secs (8000)')).toBeInTheDocument(); + + const efficiencyButton = screen.getByText( + translationEN.game.skillAtk.spInfo.efficiencyIndexes, + {selector: 'button'}, + ); + userEvent.click(efficiencyButton); + + await waitFor(() => expect(screen.getByText( + translationEN.game.skillAtk.spInfo.efficiency.modPctPer1KSp, + {selector: '.collapse.show *'}, + )).toBeInTheDocument()); + expect(screen.getByText(translationEN.game.skillAtk.spInfo.spPctPerSec)).toBeInTheDocument(); + expect(screen.getByText('2.50%')).toBeInTheDocument(); + }); + + it('does not show SP % per second if the skill is SP-based', async () => { + const calculatedData: CalculatedSkillEntry = { + ...calculatedDataTemplate, + skillEntry: { + ...calculatedDataTemplate.skillEntry, + skill: { + ...calculatedDataTemplate.skillEntry.skill, + spMax: 8000, + }, + }, + efficiency: { + ...calculatedDataTemplate.efficiency, + spFullFillSec: 0, + }, + }; + + renderReact(() => ( + <SectionSpInfo + calculatedData={calculatedData} + statusEnums={statusEnums} + /> + )); + + const efficiencyButton = screen.getByText( + translationEN.game.skillAtk.spInfo.efficiencyIndexes, + {selector: 'button'}, + ); + userEvent.click(efficiencyButton); + + await waitFor(() => expect(screen.getByText( + translationEN.game.skillAtk.spInfo.efficiency.modPctPer1KSp, + {selector: '.collapse.show *'}, + )).toBeInTheDocument()); + expect(screen.queryByText(translationEN.game.skillAtk.spInfo.spPctPerSec)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/elements/gameData/skillAtk/out/types.ts b/src/components/elements/gameData/skillAtk/out/types.ts index 6710d933..c94a7920 100644 --- a/src/components/elements/gameData/skillAtk/out/types.ts +++ b/src/components/elements/gameData/skillAtk/out/types.ts @@ -7,6 +7,7 @@ export type Efficiency = { modPctPer1KSsp: number, secPer1KSp: {[StatusCode in number]: number}, secPer1KSsp: {[StatusCode in number]: number}, + spFullFillSec: number, } export type CalculatedSkillEntry = { diff --git a/src/components/elements/gameData/skillAtk/out/utils/calc.ts b/src/components/elements/gameData/skillAtk/out/utils/calc.ts new file mode 100644 index 00000000..eb9f0e76 --- /dev/null +++ b/src/components/elements/gameData/skillAtk/out/utils/calc.ts @@ -0,0 +1,15 @@ +import {AttackingSkillData} from '../../../../../../api-def/resources'; +import {Efficiency} from '../types'; + + +export const calculateEfficiency = (totalSkillMods: number, entry: AttackingSkillData): Efficiency => ({ + modPctPer1KSp: (totalSkillMods * 100) / (entry.skill.spMax / 1000), + modPctPer1KSsp: (totalSkillMods * 100) / (entry.skill.ssSp / 1000), + secPer1KSp: Object.fromEntries(entry.skill.afflictions.map((afflictionUnit) => ( + [afflictionUnit.statusCode, afflictionUnit.duration / (entry.skill.spMax / 1000)] + ))), + secPer1KSsp: Object.fromEntries(entry.skill.afflictions.map((afflictionUnit) => ( + [afflictionUnit.statusCode, afflictionUnit.duration / (entry.skill.ssSp / 1000)] + ))), + spFullFillSec: entry.skill.spGradualPctMax ? 100 / entry.skill.spGradualPctMax : 0, +}); diff --git a/src/components/elements/gameData/skillAtk/out/utils.test.tsx b/src/components/elements/gameData/skillAtk/out/utils/entries.test.tsx similarity index 86% rename from src/components/elements/gameData/skillAtk/out/utils.test.tsx rename to src/components/elements/gameData/skillAtk/out/utils/entries.test.tsx index 23b3060e..5a21cacc 100644 --- a/src/components/elements/gameData/skillAtk/out/utils.test.tsx +++ b/src/components/elements/gameData/skillAtk/out/utils/entries.test.tsx @@ -1,9 +1,9 @@ -import {generateAttackingSkillEntry} from '../../../../../../test/data/mock/skill'; -import {AttackingSkillData, ElementBonusData} from '../../../../../api-def/resources'; -import {ResourceLoader} from '../../../../../utils/services/resources'; -import {InputData} from '../in/types'; -import {generateInputData} from '../in/utils/inputData'; -import {calculateEntries, filterSkillEntries} from './utils'; +import {generateAttackingSkillEntry} from '../../../../../../../test/data/mock/skill'; +import {AttackingSkillData, ElementBonusData} from '../../../../../../api-def/resources'; +import {ResourceLoader} from '../../../../../../utils/services/resources'; +import {InputData} from '../../in/types'; +import {generateInputData} from '../../in/utils/inputData'; +import {calculateEntries, filterSkillEntries} from './entries'; const inputDataTemplate: InputData = generateInputData(); @@ -111,6 +111,10 @@ describe('Sort ATK skill entries', () => { const inputData: InputData = { ...inputDataTemplate, sortBy: 'damage', + display: { + ...inputDataTemplate.display, + actualDamage: true, + }, }; const entries = calculateEntries(data, inputData, elemBonusData) @@ -375,4 +379,48 @@ describe('Entry calculation', () => { const entry = calculateEntries([dataModified], inputDataTemplate, elemBonusData)[0]; expect(entry.efficiency.secPer1KSsp).toStrictEqual({4: 2, 5: 4}); }); + + it('calculates SP full fill sec', async () => { + const dataModified: AttackingSkillData = { + ...data, + skill: { + ...data.skill, + spGradualPctMax: 2.5, + }, + }; + + const entry = calculateEntries([dataModified], inputDataTemplate, elemBonusData)[0]; + expect(entry.efficiency.spFullFillSec).toBe(40); + }); + + it('does not calculate SP full fill sec if the skill is SP-based', async () => { + const entry = calculateEntries([data], inputDataTemplate, elemBonusData)[0]; + expect(entry.efficiency.spFullFillSec).toBe(0); + }); + + it('does not calculate efficiency if SP info will not display', async () => { + const inputData: InputData = { + ...inputDataTemplate, + display: { + ...inputDataTemplate.display, + spInfo: false, + }, + }; + + const entry = calculateEntries([data], inputData, elemBonusData)[0]; + expect(entry.efficiency.modPctPer1KSp).toBe(0); + }); + + it('does not calculate actual damage if actual damage will not display', async () => { + const inputData: InputData = { + ...inputDataTemplate, + display: { + ...inputDataTemplate.display, + actualDamage: false, + }, + }; + + const entry = calculateEntries([data], inputData, elemBonusData)[0]; + expect(entry.skillDamage.expected).toBe(0); + }); }); diff --git a/src/components/elements/gameData/skillAtk/out/utils.ts b/src/components/elements/gameData/skillAtk/out/utils/entries.ts similarity index 58% rename from src/components/elements/gameData/skillAtk/out/utils.ts rename to src/components/elements/gameData/skillAtk/out/utils/entries.ts index d799755f..75392c1d 100644 --- a/src/components/elements/gameData/skillAtk/out/utils.ts +++ b/src/components/elements/gameData/skillAtk/out/utils/entries.ts @@ -1,8 +1,9 @@ -import {AttackingSkillData, ElementBonusData} from '../../../../../api-def/resources'; -import {calculateDamage} from '../../../../../utils/game/damage'; -import {InputData} from '../in/types'; -import {sortFunc} from '../sorter/lookup'; -import {CalculatedSkillEntry, Efficiency} from './types'; +import {AttackingSkillData, ElementBonusData} from '../../../../../../api-def/resources'; +import {calculateDamage} from '../../../../../../utils/game/damage'; +import {InputData} from '../../in/types'; +import {sortFunc} from '../../sorter/lookup'; +import {CalculatedSkillEntry, Efficiency} from '../types'; +import {calculateEfficiency} from './calc'; export const filterSkillEntries = (inputData: InputData, atkSkillEntries: Array<AttackingSkillData>) => { @@ -39,34 +40,29 @@ export const calculateEntries = ( atkSkillEntries: Array<AttackingSkillData>, inputData: InputData, elemBonusData: ElementBonusData, ): Array<CalculatedSkillEntry> => { return atkSkillEntries - .map((entry: AttackingSkillData) => { + .map((skillEntry: AttackingSkillData) => { // Element bonus rate const charaElementRate = elemBonusData.getElementBonus( - String(entry.chara.element), + String(skillEntry.chara.element), String(inputData.target.elemCondCode), ); // Calculate skill damage - const skillDamage = calculateDamage(inputData, entry, charaElementRate); + const skillDamage = calculateDamage(inputData, skillEntry, charaElementRate); // Calculate efficiency - const efficiency: Efficiency = { - modPctPer1KSp: (skillDamage.totalMods * 100) / (entry.skill.spMax / 1000), - modPctPer1KSsp: (skillDamage.totalMods * 100) / (entry.skill.ssSp / 1000), - secPer1KSp: Object.fromEntries(entry.skill.afflictions.map((afflictionUnit) => ( - [afflictionUnit.statusCode, afflictionUnit.duration / (entry.skill.spMax / 1000)] - ))), - secPer1KSsp: Object.fromEntries(entry.skill.afflictions.map((afflictionUnit) => ( - [afflictionUnit.statusCode, afflictionUnit.duration / (entry.skill.ssSp / 1000)] - ))), - }; + const efficiency: Efficiency = inputData.display.spInfo ? + calculateEfficiency(skillDamage.totalMods, skillEntry) : + { + modPctPer1KSp: 0, + modPctPer1KSsp: 0, + secPer1KSp: {}, + secPer1KSsp: {}, + spFullFillSec: 0, + }; - return { - skillDamage, - skillEntry: entry, - efficiency, - }; + return {skillDamage, skillEntry, efficiency}; }) - .filter((calcData) => calcData.skillDamage.expected > 0) + .filter((calcData) => calcData.skillDamage.totalMods > 0) .sort(sortFunc[inputData.sortBy]); }; diff --git a/src/components/elements/gameData/skillAtk/preset/main.test.tsx b/src/components/elements/gameData/skillAtk/preset/main.test.tsx index b030cddf..f4defc66 100644 --- a/src/components/elements/gameData/skillAtk/preset/main.test.tsx +++ b/src/components/elements/gameData/skillAtk/preset/main.test.tsx @@ -6,6 +6,8 @@ import userEvent from '@testing-library/user-event'; import {renderReact} from '../../../../../../test/render/main'; import {ApiResponseCode} from '../../../../../api-def/api'; import {ApiRequestSender} from '../../../../../utils/services/api/requestSender'; +import {PRESET_QUERY_NAME} from '../hooks/preset'; +import {generateInputData} from '../in/utils/inputData'; import {AttackingSkillPreset} from './main'; @@ -14,6 +16,9 @@ describe('ATK skill input preset manager', () => { beforeEach(() => { window.location.href = 'http://localhost:3000'; + // @ts-ignore + // noinspection JSConstantReassignment + navigator.clipboard = {writeText: jest.fn().mockResolvedValue(void 0)}; fnMakePreset = jest.spyOn(ApiRequestSender, 'setPresetAtkSkill').mockResolvedValue({ code: ApiResponseCode.SUCCESS, success: true, @@ -22,8 +27,9 @@ describe('ATK skill input preset manager', () => { }); it('makes a preset on clicking share', async () => { + const inputData = generateInputData(); renderReact( - () => <AttackingSkillPreset isEnabled/>, + () => <AttackingSkillPreset inputData={inputData} isEnabled/>, {hasSession: true}, ); @@ -31,5 +37,72 @@ describe('ATK skill input preset manager', () => { userEvent.click(shareButton); await waitFor(() => expect(fnMakePreset).toHaveBeenCalled()); + expect(fnMakePreset.mock.calls[0][1]).toBe(inputData); + }); + + it('shows clipboard icon and link after making a preset and copied once', async () => { + jest.useFakeTimers(); + + renderReact( + () => <AttackingSkillPreset inputData={generateInputData()} isEnabled/>, + {hasSession: true}, + ); + + const shareButton = screen.getByText('', {selector: 'i.bi-share-fill'}); + userEvent.click(shareButton); + + await waitFor(() => expect(fnMakePreset).toHaveBeenCalled()); + jest.runTimersToTime(7000); + await waitFor(() => expect(screen.getByText('', {selector: 'i.bi-clipboard'})).toBeInTheDocument()); + expect(screen.getByDisplayValue(`http://localhost/?${PRESET_QUERY_NAME}=presetLink`)).toBeInTheDocument(); + + jest.useRealTimers(); + }); + + it('copies the correct link if re-copy', async () => { + jest.useFakeTimers(); + + renderReact( + () => <AttackingSkillPreset inputData={generateInputData()} isEnabled/>, + {hasSession: true}, + ); + + const shareButton = screen.getByText('', {selector: 'i.bi-share-fill'}); + userEvent.click(shareButton); + + await waitFor(() => expect(fnMakePreset).toHaveBeenCalled()); + jest.runTimersToTime(7000); + + const clipboardButton = screen.getByText('', {selector: 'i.bi-clipboard'}); + userEvent.click(clipboardButton); + + const expectedLink = `http://localhost/?${PRESET_QUERY_NAME}=presetLink`; + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenLastCalledWith(expectedLink)); + + jest.useRealTimers(); + }); + + it('does not have 2 preset IDs in the new link if created twice', async () => { + jest.spyOn(ApiRequestSender, 'getPresetAtkSkill').mockResolvedValue({ + code: ApiResponseCode.SUCCESS, + success: true, + preset: {a: true}, + }); + window.location.href = `http://localhost/?${PRESET_QUERY_NAME}=preset`; + + renderReact( + () => <AttackingSkillPreset inputData={generateInputData()} isEnabled/>, + { + hasSession: true, + routerOptions: {query: {[PRESET_QUERY_NAME]: 'preset'}}, + }, + ); + + const shareButton = screen.getByText('', {selector: 'i.bi-share-fill'}); + userEvent.click(shareButton); + + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalled()); + const copiedLink = (navigator.clipboard.writeText as jest.Mock).mock.calls[0][0]; + expect(new URL(copiedLink).searchParams.getAll(PRESET_QUERY_NAME).length).toBe(1); }); }); diff --git a/src/components/elements/gameData/skillAtk/preset/main.tsx b/src/components/elements/gameData/skillAtk/preset/main.tsx index ebc69fad..823f4001 100644 --- a/src/components/elements/gameData/skillAtk/preset/main.tsx +++ b/src/components/elements/gameData/skillAtk/preset/main.tsx @@ -5,10 +5,13 @@ import FormControl from 'react-bootstrap/FormControl'; import InputGroup from 'react-bootstrap/InputGroup'; import Spinner from 'react-bootstrap/Spinner'; +import {AppReactContext} from '../../../../../context/app/main'; import {useI18n} from '../../../../../i18n/hook'; -import {CommonModal, ModalState} from '../../../common/modal'; -import {useAtkSkillInput} from '../hooks/preset'; -import {PresetStatus} from './types'; +import {ApiRequestSender} from '../../../../../utils/services/api/requestSender'; +import {CommonModal} from '../../../common/modal'; +import {PRESET_QUERY_NAME} from '../hooks/preset'; +import {InputData} from '../in/types'; +import {PresetState, PresetStatus} from './types'; const statusButtonIcon: { [status in PresetStatus]: React.ReactElement } = { @@ -19,69 +22,100 @@ const statusButtonIcon: { [status in PresetStatus]: React.ReactElement } = { }; type Props = { + inputData: InputData, isEnabled: boolean, } -export const AttackingSkillPreset = ({isEnabled}: Props) => { +export const AttackingSkillPreset = ({inputData, isEnabled}: Props) => { const {t} = useI18n(); - const [status, setStatus] = React.useState<PresetStatus>('notCreated'); - const [presetLink, setPresetLink] = React.useState<string>(t((t) => t.game.skillAtk.info.preset)); - const [modalState, setModalState] = React.useState<ModalState>({ - show: false, - title: '', - message: '', - }); - const {makePreset, makePresetLink} = useAtkSkillInput(() => { - setStatus('notCreated'); - setModalState({ - ...modalState, - show: true, - message: t((t) => t.game.skillAtk.error.presetMustLogin), - }); + const context = React.useContext(AppReactContext); + + const [state, setState] = React.useState<PresetState>({ + status: 'notCreated', + link: t((t) => t.game.skillAtk.info.preset), + modal: { + show: false, + title: '', + message: '', + }, }); - const copyAndSetTimeout = (link: string) => { - navigator.clipboard.writeText(link).then(() => setStatus('copied')); - return setTimeout(() => setStatus('createdNotCopied'), 5000); + React.useEffect(() => { + setState({...state, status: 'notCreated', link: t((t) => t.game.skillAtk.info.preset)}); + }, [inputData]); + + const makePreset = () => { + setState({...state, status: 'creating'}); + if (!context?.session) { + setState({ + ...state, + status: 'notCreated', + modal: { + title: 'Error', + show: true, + message: t((t) => t.game.skillAtk.error.presetMustLogin), + }, + }); + return; + } + + ApiRequestSender.setPresetAtkSkill(context.session.user.id.toString(), inputData) + .then((response) => { + const link = new URL(window.location.href); + link.searchParams.set(PRESET_QUERY_NAME, response.presetId); + + copyAndSetTimeout(link.href); + }) + .catch((e) => { + setState({ + ...state, + status: 'notCreated', + modal: { + title: 'Error', + show: true, + message: e.message, + }, + }); + }); + }; + + const copyAndSetTimeout = (link: string = state.link) => { + navigator.clipboard.writeText(link).then(() => setState({...state, status: 'copied', link})); + return setTimeout(() => setState({...state, status: 'createdNotCopied', link}), 5000); }; const onClickShareButton = () => { - if (status === 'notCreated') { - setStatus('creating'); + if (state.status === 'notCreated') { makePreset(); } - if (status === 'createdNotCopied' && makePresetLink) { - const timeout: NodeJS.Timeout = copyAndSetTimeout(makePresetLink); - return () => clearTimeout(timeout); + if (state.status === 'createdNotCopied') { + copyAndSetTimeout(); } }; - if (status === 'creating' && makePresetLink) { - setPresetLink(makePresetLink); - copyAndSetTimeout(makePresetLink); - setStatus('copied'); - } - return ( <> - <CommonModal modalState={modalState} setModalState={setModalState}/> + <CommonModal + modalState={state.modal} + setModalState={(modalState) => setState({...state, modal: modalState})} + /> <InputGroup className="mb-2 mr-sm-2"> <FormControl - className={`bg-black-32 ${status === 'copied' ? 'text-info' : 'text-light'}`} disabled + className={`bg-black-32 ${state.status === 'copied' ? 'text-info' : 'text-light'}`} disabled value={ - status === 'copied' ? + state.status === 'copied' ? t((t) => t.game.skillAtk.info.presetExpiry) : - presetLink + state.link } /> <InputGroup.Append> <Button className="d-flex align-items-center" variant="outline-light" onClick={onClickShareButton} - disabled={!isEnabled || status === 'creating' || status === 'copied'} + disabled={!isEnabled || state.status === 'creating' || state.status === 'copied'} > - {statusButtonIcon[status]} + {statusButtonIcon[state.status]} </Button> </InputGroup.Append> </InputGroup> diff --git a/src/components/elements/gameData/skillAtk/preset/types.ts b/src/components/elements/gameData/skillAtk/preset/types.ts index 07dcd563..260b215a 100644 --- a/src/components/elements/gameData/skillAtk/preset/types.ts +++ b/src/components/elements/gameData/skillAtk/preset/types.ts @@ -1,5 +1,14 @@ +import {ModalState} from '../../../common/modal'; + + export type PresetStatus = 'notCreated' | 'creating' | 'copied' | 'createdNotCopied'; + +export type PresetState = { + status: PresetStatus, + link: string, + modal: ModalState, +} diff --git a/src/components/elements/gameData/skillAtk/sorter/main.test.tsx b/src/components/elements/gameData/skillAtk/sorter/main.test.tsx index 1cbdcfd4..36e5f9ce 100644 --- a/src/components/elements/gameData/skillAtk/sorter/main.test.tsx +++ b/src/components/elements/gameData/skillAtk/sorter/main.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import {renderReact} from '../../../../../../test/render/main'; import {translation as translationEN} from '../../../../../i18n/translations/en/translation'; +import {generateInputData} from '../in/utils/inputData'; import {AttackingSkillSorter} from './main'; @@ -16,13 +17,23 @@ describe('ATK skill entry sorter', () => { }); it('shows the current sort order', async () => { - renderReact(() => <AttackingSkillSorter onOrderPicked={onOrderPicked}/>); - - expect(screen.getByText(`Order: ${translationEN.game.skillAtk.sort.damageDesc}`)).toBeInTheDocument(); + renderReact(() => ( + <AttackingSkillSorter + inputData={generateInputData({sortBy: 'sp'})} + onOrderPicked={onOrderPicked} + /> + )); + + expect(screen.getByText(`Order: ${translationEN.game.skillAtk.sort.sp}`)).toBeInTheDocument(); }); it('dispatches on order picked event', async () => { - renderReact(() => <AttackingSkillSorter onOrderPicked={onOrderPicked}/>); + renderReact(() => ( + <AttackingSkillSorter + inputData={generateInputData()} + onOrderPicked={onOrderPicked} + /> + )); const dropdownButton = screen.getByText(/Order/); userEvent.click(dropdownButton); diff --git a/src/components/elements/gameData/skillAtk/sorter/main.tsx b/src/components/elements/gameData/skillAtk/sorter/main.tsx index f45eee15..9c92cb46 100644 --- a/src/components/elements/gameData/skillAtk/sorter/main.tsx +++ b/src/components/elements/gameData/skillAtk/sorter/main.tsx @@ -4,28 +4,24 @@ import Dropdown from 'react-bootstrap/Dropdown'; import DropdownButton from 'react-bootstrap/DropdownButton'; import {useI18n} from '../../../../../i18n/hook'; -import {useAtkSkillInput} from '../hooks/preset'; import {InputData, SortBy} from '../in/types'; import {overwriteInputData} from '../in/utils/inputData'; import {orderName} from './lookup'; type Props = { + inputData: InputData, onOrderPicked: (newInputData: InputData) => void, } -export const AttackingSkillSorter = ({onOrderPicked}: Props) => { +export const AttackingSkillSorter = ({inputData, onOrderPicked}: Props) => { const {t} = useI18n(); - const {inputData, setInputData} = useAtkSkillInput(); - const sortBy = t(orderName[inputData.sortBy]); const title = t((t) => t.game.skillAtk.sort.text, {sortBy}); const onItemPicked = (sortBy: SortBy) => () => { const newInputData = overwriteInputData(inputData, {sortBy}); - setInputData(newInputData); - onOrderPicked(newInputData); }; diff --git a/src/components/elements/markdown/main.image.test.tsx b/src/components/elements/markdown/main.image.test.tsx index a7d3ec6e..51b6c570 100644 --- a/src/components/elements/markdown/main.image.test.tsx +++ b/src/components/elements/markdown/main.image.test.tsx @@ -17,7 +17,7 @@ describe('Markdown (Image)', () => { }); it('shows unit image as icon', async () => { - renderReact(() => <Markdown>{'![Alt](https://i.imgur.com/mtxtE5j.jpeg|unitIcon)'}</Markdown>); + renderReact(() => <Markdown>{'![Alt](https://i.imgur.com/mtxtE5j.jpeg[unitIcon])'}</Markdown>); const image = screen.getByAltText('Alt'); expect(image).toHaveAttribute('src', 'https://i.imgur.com/mtxtE5j.jpeg'); diff --git a/src/components/elements/markdown/main.module.css b/src/components/elements/markdown/main.module.css index c2c10b3b..550bab9a 100644 --- a/src/components/elements/markdown/main.module.css +++ b/src/components/elements/markdown/main.module.css @@ -100,6 +100,8 @@ .mdBody ul, .mdBody dl { padding-inline-start: 25px; +} +.mdBody > ol, .mdBody > ul, .mdBody > dl { margin-bottom: 0.75rem; } .mdBody h1, diff --git a/src/components/elements/markdown/main.module.css.map b/src/components/elements/markdown/main.module.css.map index e8d0864f..bbfffff0 100644 --- a/src/components/elements/markdown/main.module.css.map +++ b/src/components/elements/markdown/main.module.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["main.module.scss","../../../../public/colors.scss"],"names":[],"mappings":"AAMA;EACE;EACA,kBCHe;EDIf;EACA;EACA;;AAEA;EAGE;EACA;EACA,eAdU;;AAgBV;EACE;EACA;;AAGF;EACE;;AAGF;AAAA;EAEE;EACA;EACA;;AAIA;EACE;;AAGF;EACE;;AAGF;EACE;;AAIJ;EACE;IACE;;EAGF;IACE;IACA;IACA;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IACE;IACA,eApEM;;EAuER;IACE;;;AAMJ;EACE,eA/EQ;;AAkFV;EACE;EACA;EACA;;AAKF;EACE;EACA;EACA;EACA;;AAEA;EACE;;AAMJ;EACE;EACA;EACA;EACA;EACA;EACA;;AAIJ;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA,OCvHe;EDwHf;;AAGF;EACE,OAlIW;;AAqIb;AAAA;AAAA;EAGE;EACA,eAvIU;;AA0IZ;AAAA;AAAA;EAGE;EACA;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;EACA,eAxJU;EAyJV","file":"main.module.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["main.module.scss","../../../../public/colors.scss"],"names":[],"mappings":"AAMA;EACE;EACA,kBCHe;EDIf;EACA;EACA;;AAEA;EAGE;EACA;EACA,eAdU;;AAgBV;EACE;EACA;;AAGF;EACE;;AAGF;AAAA;EAEE;EACA;EACA;;AAIA;EACE;;AAGF;EACE;;AAGF;EACE;;AAIJ;EACE;IACE;;EAGF;IACE;IACA;IACA;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IACE;IACA,eApEM;;EAuER;IACE;;;AAMJ;EACE,eA/EQ;;AAkFV;EACE;EACA;EACA;;AAKF;EACE;EACA;EACA;EACA;;AAEA;EACE;;AAMJ;EACE;EACA;EACA;EACA;EACA;EACA;;AAIJ;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA,OCvHe;EDwHf;;AAGF;EACE,OAlIW;;AAqIb;AAAA;AAAA;EAGE;;AAGF;EAGE,eA5IU;;AA+IZ;AAAA;AAAA;EAGE;EACA;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;EACA,eA7JU;EA8JV","file":"main.module.css"} \ No newline at end of file diff --git a/src/components/elements/markdown/main.module.scss b/src/components/elements/markdown/main.module.scss index e07b2f9d..dfd28b64 100644 --- a/src/components/elements/markdown/main.module.scss +++ b/src/components/elements/markdown/main.module.scss @@ -137,6 +137,11 @@ $item-margin: 0.75rem; ul, dl { padding-inline-start: 25px; + } + + & > ol, + & > ul, + & > dl { margin-bottom: $item-margin; } diff --git a/src/components/elements/markdown/transformers/image/const.ts b/src/components/elements/markdown/transformers/image/const.ts index f673d431..f754de60 100644 --- a/src/components/elements/markdown/transformers/image/const.ts +++ b/src/components/elements/markdown/transformers/image/const.ts @@ -1 +1 @@ -export const IMAGE_CLASS_SPLITTER = '%7C'; // Escaped "|" +export const IMAGE_REGEX = /(?<src>(?:(?!%5B).)+)(?:%5B(?<className>[\w ]+)%5D)?/; // %5B = [ | %5D = ] diff --git a/src/components/elements/markdown/transformers/image/main.test.tsx b/src/components/elements/markdown/transformers/image/main.test.tsx new file mode 100644 index 00000000..1d201987 --- /dev/null +++ b/src/components/elements/markdown/transformers/image/main.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import {screen} from '@testing-library/react'; + +import {renderReact} from '../../../../../../test/render/main'; +import {ImageInHTML} from './main'; + + +describe('Image in HTML', () => { + it('renders image with specified class names', async () => { + renderReact(() => <ImageInHTML src="https://i.imgur.com/mtxtE5j.jpeg%5BclassName%5D" alt="alt"/>); + + expect(screen.getByAltText('alt')).toHaveClass('className'); + }); + + it('renders image if no class name', async () => { + renderReact(() => <ImageInHTML src="https://i.imgur.com/mtxtE5j.jpeg" alt="alt"/>); + + expect(screen.getByAltText('alt')).toHaveAttribute('src', 'https://i.imgur.com/mtxtE5j.jpeg'); + }); +}); diff --git a/src/components/elements/markdown/transformers/image/main.tsx b/src/components/elements/markdown/transformers/image/main.tsx index 29282a7b..f4416642 100644 --- a/src/components/elements/markdown/transformers/image/main.tsx +++ b/src/components/elements/markdown/transformers/image/main.tsx @@ -6,7 +6,7 @@ import {useI18n} from '../../../../../i18n/hook'; import {GoogleAnalytics} from '../../../../../utils/services/ga'; import {Image} from '../../../common/image'; import {CommonModal, ModalState} from '../../../common/modal'; -import {IMAGE_CLASS_SPLITTER} from './const'; +import {IMAGE_REGEX} from './const'; import {ImageProps} from './types'; @@ -19,7 +19,7 @@ export const ImageInHTML = ({src, alt}: ImageProps) => { message: '', }); - const [actualSrc, className] = src.split(IMAGE_CLASS_SPLITTER, 2); + const {src: actualSrc, className} = src.match(IMAGE_REGEX)?.groups || {}; const openGifModal = () => { GoogleAnalytics.showGif(actualSrc); diff --git a/src/components/elements/posts/analysis/output/dragonBody.tsx b/src/components/elements/posts/analysis/output/dragonBody.tsx index 258a5278..5c2bfbf5 100644 --- a/src/components/elements/posts/analysis/output/dragonBody.tsx +++ b/src/components/elements/posts/analysis/output/dragonBody.tsx @@ -17,7 +17,7 @@ export const AnalysisOutputDragonBody = ({analysis}: SectionProps<DragonAnalysis <AdsInPost/> <h3 className="mb-3">{t((t) => t.posts.analysis.notesDragon)}</h3> <Markdown>{analysis.notes}</Markdown> - <h3 className="mb-3">{t((t) => t.posts.analysis.suitable)}</h3> + <h3 className="my-3">{t((t) => t.posts.analysis.suitable)}</h3> <Markdown>{analysis.suitableCharacters}</Markdown> </> ); diff --git a/src/components/elements/posts/analysis/output/top.tsx b/src/components/elements/posts/analysis/output/top.tsx index 0c0161f6..a512ddc2 100644 --- a/src/components/elements/posts/analysis/output/top.tsx +++ b/src/components/elements/posts/analysis/output/top.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {AnalysisGetResponse} from '../../../../../api-def/api'; import {useI18n} from '../../../../../i18n/hook'; import {AdsInPost} from '../../../common/ads/main'; +import {OverlayPopover} from '../../../common/overlay/popover'; import {Markdown} from '../../../markdown/main'; import {SectionProps} from './props'; @@ -22,7 +23,13 @@ export const SectionTop = <R extends AnalysisGetResponse>({analysis}: SectionPro <> <hr/> <h3 className="mb-3"> - {t((t) => t.posts.analysis.summonResult)} + {t((t) => t.posts.analysis.summonResult)} + <OverlayPopover + title={t((t) => t.posts.analysis.summonExplanation.title)} + content={t((t) => t.posts.analysis.summonExplanation.description)} + > + <i className="bi bi-info-circle"/> + </OverlayPopover> </h3> <Markdown>{analysis.summonResult}</Markdown> </> diff --git a/src/components/elements/posts/quest/form/general.tsx b/src/components/elements/posts/quest/form/general.tsx index 7a9995f2..d6745722 100644 --- a/src/components/elements/posts/quest/form/general.tsx +++ b/src/components/elements/posts/quest/form/general.tsx @@ -1,21 +1,22 @@ import React from 'react'; import Col from 'react-bootstrap/Col'; -import Row from 'react-bootstrap/Row'; +import Form from 'react-bootstrap/Form'; import {QuestPostPublishPayload} from '../../../../../api-def/api'; import {useI18n} from '../../../../../i18n/hook'; import {MarkdownInput} from '../../../markdown/input'; import {PostFormDataProps} from '../../shared/form/types'; + export const FormGeneralInfo = <P extends QuestPostPublishPayload>({formState, setPayload}: PostFormDataProps<P>) => { const {t} = useI18n(); const {payload} = formState; return ( - <Row> - <Col className="pr-2" lg={6}> + <Form.Row> + <Col className="mb-3 mb-lg-0" lg={6}> <h5>{t((t) => t.posts.quest.general)}</h5> <MarkdownInput onChanged={(e) => setPayload('general', e.target.value)} @@ -23,13 +24,13 @@ export const FormGeneralInfo = <P extends QuestPostPublishPayload>({formState, s required /> </Col> - <Col className="pl-2" lg={6}> + <Col lg={6}> <h5>{t((t) => t.posts.quest.video)}</h5> <MarkdownInput onChanged={(e) => setPayload('video', e.target.value)} rows={5} value={payload.video} /> </Col> - </Row> + </Form.Row> ); }; diff --git a/src/i18n/translations/cht/translation.ts b/src/i18n/translations/cht/translation.ts index c4ed112f..d0bb0fe5 100644 --- a/src/i18n/translations/cht/translation.ts +++ b/src/i18n/translations/cht/translation.ts @@ -222,6 +222,8 @@ export const translation: TranslationStruct = { secPer1KSsp: '異常效期 (秒) / 1K SSP', }, sp: 'SP', + spGradualFill: '{{secs}} 秒 ({{sp}})', + spPctPerSec: '每秒回復 SP %', ssp: 'SSP', ssCost: 'SS Cost', }, @@ -389,6 +391,12 @@ export const translation: TranslationStruct = { suitable: '適配角色', summary: '懶人包', summonResult: '個人抽抽結果', + summonExplanation: { + title: '關於這個區塊', + description: '有些人可能會好奇為何這個部分會出現在評測中。' + + '一剛開始我在寫評測的時候,我想讓我的觀眾知道幾抽就中只是純粹好運,需要幾十、幾百,甚至天井才有是很正常的事情。' + + '我從來沒有想過最後我會這麼認真寫評測,而且我也一樣想讓我們觀眾們知道前面提到關於抽卡的事情,所以我決定把這個習慣保留下來。', + }, tipsBuilds: '要點 & 建議配置', ultimate: '大招', videos: '相關影片', diff --git a/src/i18n/translations/definition.ts b/src/i18n/translations/definition.ts index 0bf8036e..b960dab4 100644 --- a/src/i18n/translations/definition.ts +++ b/src/i18n/translations/definition.ts @@ -214,8 +214,10 @@ export type TranslationStruct = { }, spInfo: { efficiencyIndexes: string, - efficiency: {[index in keyof Efficiency]: string}, + efficiency: {[index in keyof Omit<Efficiency, 'spFullFillSec'>]: string}, sp: string, + spGradualFill: string, + spPctPerSec: string, ssp: string, ssCost: string, }, @@ -317,6 +319,10 @@ export type TranslationStruct = { suitable: string, summary: string, summonResult: string, + summonExplanation: { + title: string, + description: string, + }, tipsBuilds: string, ultimate: string, videos: string, diff --git a/src/i18n/translations/en/translation.ts b/src/i18n/translations/en/translation.ts index 3970c81e..6cda6bbb 100644 --- a/src/i18n/translations/en/translation.ts +++ b/src/i18n/translations/en/translation.ts @@ -246,6 +246,8 @@ export const translation: TranslationStruct = { secPer1KSsp: 'Affliction (sec) / 1K SSP', }, sp: 'SP', + spGradualFill: '{{secs}} secs ({{sp}})', + spPctPerSec: 'SP Regen % / sec', ssp: 'SSP', ssCost: 'SS Cost', }, @@ -418,6 +420,16 @@ export const translation: TranslationStruct = { suitable: 'Suitable Characters', summary: 'Summary', summonResult: 'My Summoning Result', + summonExplanation: { + title: 'About this section', + description: 'Some people may wonder why this is listed in my analysis. ' + + 'When I started writing analysis, ' + + 'I wanted to let people know that getting lucky is pure luck; ' + + 'getting unlucky is just a usual thing. ' + + 'I never thought that I would end up dedicating writing analysis like this, ' + + 'and I still want to let people know what was mentioned about luck, ' + + 'so I decided to leave it instead of removing it.', + }, tipsBuilds: 'Tips & Builds', ultimate: 'Ultimate', videos: 'Related Videos', diff --git a/src/i18n/translations/jp/translation.ts b/src/i18n/translations/jp/translation.ts index f3a4eccd..55619b30 100644 --- a/src/i18n/translations/jp/translation.ts +++ b/src/i18n/translations/jp/translation.ts @@ -221,6 +221,8 @@ export const translation: TranslationStruct = { secPer1KSsp: '異常效期 (秒) / 1K SSP', }, sp: 'SP', + spGradualFill: '{{secs}} secs ({{sp}})', + spPctPerSec: 'SP Regen % / sec', ssp: 'SSP', ssCost: 'SS Cost', }, @@ -386,6 +388,16 @@ export const translation: TranslationStruct = { suitable: '相性良いキャラ', summary: '結論', summonResult: '個人のガチャ結果', + summonExplanation: { + title: 'About this section', + description: 'Some people may wonder why this is listed in my analysis. ' + + 'When I started writing analysis, ' + + 'I wanted to let people know that getting lucky is pure luck; ' + + 'getting unlucky is just a usual thing. ' + + 'I never thought that I would end up dedicating writing analysis like this, ' + + 'and I still want to let people know what was mentioned about luck, ' + + 'so I decided to leave it instead of removing it.', + }, tipsBuilds: 'ポイント & おすすめ装備編成', ultimate: '必殺技', videos: '関する動画', diff --git a/src/utils/game/damage.test.ts b/src/utils/game/damage.test.ts index baff9e9d..941bfdf8 100644 --- a/src/utils/game/damage.test.ts +++ b/src/utils/game/damage.test.ts @@ -50,6 +50,9 @@ describe('Damage calculation', () => { }, state: ConditionCodes.TARGET_STATE_BK, }, + display: { + actualDamage: true, + }, }); const attackingSkillData = { diff --git a/src/utils/game/damage.ts b/src/utils/game/damage.ts index 97c2322f..57645f21 100644 --- a/src/utils/game/damage.ts +++ b/src/utils/game/damage.ts @@ -47,6 +47,11 @@ export const calculateDamage = ( }) .reduce((a, b) => a + b, 0); + // Omit numeric calculations if not to display + if (!inputData.display.actualDamage) { + return {lowest: 0, expected: 0, highest: 0, totalMods}; + } + let damage = 5 / 3; // Base damage // Damage from ATK @@ -96,10 +101,5 @@ export const calculateDamage = ( // Special - Bog damage *= inputData.target.afflictionCodes.includes(ConditionCodes.TARGET_BOGGED) ? 1.5 : 1; - return { - lowest: damage * 0.95, - expected: damage, - highest: damage * 1.05, - totalMods: totalMods, - }; + return {lowest: damage * 0.95, expected: damage, highest: damage * 1.05, totalMods}; }; diff --git a/src/utils/process/text.test.ts b/src/utils/process/text.test.tsx similarity index 79% rename from src/utils/process/text.test.ts rename to src/utils/process/text.test.tsx index de071f3d..4e1558ac 100644 --- a/src/utils/process/text.test.ts +++ b/src/utils/process/text.test.tsx @@ -1,6 +1,12 @@ +import React from 'react'; + +import {screen} from '@testing-library/react'; + import {generateGalaMymInfo} from '../../../test/data/mock/unitInfo'; +import {renderReact} from '../../../test/render/main'; import {SupportedLanguages} from '../../api-def/api'; -import {DepotPaths} from '../../api-def/resources/paths'; +import {DepotPaths} from '../../api-def/resources'; +import {Markdown} from '../../components/elements/markdown/main'; import {PostPath} from '../../const/path/definitions'; import {translations} from '../../i18n/translations/main'; import {makePostPath} from '../path/make'; @@ -12,7 +18,7 @@ describe('Process text', () => { const lang = SupportedLanguages.EN; const galaMymAnalysisLink = `[Gala Mym](${makePostPath(PostPath.ANALYSIS, {pid: 10550101, lang})})`; - const galaMymImageMd = `![Gala Mym](${DepotPaths.getCharaIconURL('100010_04_r05')}|unitIcon)`; + const galaMymImageMd = `![Gala Mym](${DepotPaths.getCharaIconURL('100010_04_r05')}[unitIcon])`; const galaMymMdTransformed = `${galaMymImageMd}${galaMymAnalysisLink}`; beforeEach(() => { @@ -57,4 +63,14 @@ describe('Process text', () => { expect(result).toBe(text); }); + + it('renders correctly for unit icon in table cell', async () => { + const text = 'head | col 2\n:---: | :---:\n:Gala Mym: | Y'; + + const result = await processText({text, lang}); + + renderReact(() => <Markdown>{result}</Markdown>); + + expect(screen.getByAltText('Gala Mym')).toBeInTheDocument(); + }); }); diff --git a/src/utils/process/transformers/quickReference.test.ts b/src/utils/process/transformers/quickReference.test.ts index a4c6b838..15a7fe70 100644 --- a/src/utils/process/transformers/quickReference.test.ts +++ b/src/utils/process/transformers/quickReference.test.ts @@ -15,7 +15,7 @@ import {transformQuickReference} from './quickReference'; const lang = SupportedLanguages.EN; const galaMymAnalysisLink = `[Gala Mym](${makePostPath(PostPath.ANALYSIS, {pid: 10550101, lang})})`; -const galaMymImageMd = `![Gala Mym](${DepotPaths.getCharaIconURL('100010_04_r05')}|unitIcon)`; +const galaMymImageMd = `![Gala Mym](${DepotPaths.getCharaIconURL('100010_04_r05')}[unitIcon])`; const galaMymMdTransformed = `${galaMymImageMd}${galaMymAnalysisLink}`; describe('Quick reference transformer (Quest/Misc/Mixed)', () => { @@ -148,7 +148,7 @@ describe('Quick reference transformer (Analysis)', () => { it('matches greedily', async () => { const brunhildaExtAnalysisLink = `[BrunhildaExtended](${makePostPath(PostPath.ANALYSIS, {pid: 20050102, lang})})`; - const brunhildaExtImageMd = `![BrunhildaExtended](${DepotPaths.getDragonIconURL('210039_01')}|unitIcon)`; + const brunhildaExtImageMd = `![BrunhildaExtended](${DepotPaths.getDragonIconURL('210039_01')}[unitIcon])`; const brunhildaExtMdTransformed = `${brunhildaExtImageMd}${brunhildaExtAnalysisLink}`; const text = ':BrunhildaExtended:'; diff --git a/src/utils/process/transformers/quickReference.ts b/src/utils/process/transformers/quickReference.ts index e3a180d1..d9b848cb 100644 --- a/src/utils/process/transformers/quickReference.ts +++ b/src/utils/process/transformers/quickReference.ts @@ -39,7 +39,7 @@ const transformAnalysis: TextTransformer = async ({text, lang}) => { } const postPath = makePostPath(PostPath.ANALYSIS, {pid: unitInfo.id, lang}); - const imageMd = `![${unitName}](${getImageURL(unitInfo)}|unitIcon)`; + const imageMd = `![${unitName}](${getImageURL(unitInfo)}[unitIcon])`; leftRemainder = leftRemainder.replace(/:/g, ''); rightRemainder = rightRemainder.replace(/:/g, ''); diff --git a/test/data/mock/skill.ts b/test/data/mock/skill.ts index 764b1e6f..b3ee7b22 100644 --- a/test/data/mock/skill.ts +++ b/test/data/mock/skill.ts @@ -48,6 +48,7 @@ export const generateAttackingSkillEntry = (): AttackingSkillData => ({ }, sharable: false, spMax: 9999, + spGradualPctMax: 0, ssCost: 5, ssSp: 17777, hitTimingSecMax: [], @@ -75,5 +76,6 @@ export const generateCalculatedEntry = (): CalculatedSkillEntry => ({ 3: 3.5757575757, 4: 6.0131313131, }, + spFullFillSec: 0, }, }); diff --git a/test/data/resources b/test/data/resources index 529c6514..58227f5f 160000 --- a/test/data/resources +++ b/test/data/resources @@ -1 +1 @@ -Subproject commit 529c65142d2d569c88671f4f44d985abd5547af2 +Subproject commit 58227f5f342107b6d99eb160a3c824bce68521ee