mirror of
https://github.com/kremalicious/portfolio.git
synced 2024-12-22 17:23:22 +01:00
refactorings
This commit is contained in:
parent
0e78f34d15
commit
f5f6c0b5fd
@ -1,6 +1,6 @@
|
||||
import './src/styles/global.scss'
|
||||
import React from 'react'
|
||||
import AppProvider from './src/store/provider'
|
||||
import AppProvider from './src/store/Provider'
|
||||
import wrapPageElementWithTransition from './src/helpers/wrapPageElement'
|
||||
|
||||
// IntersectionObserver polyfill for gatsby-image (Safari, IE)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import AppProvider from './src/store/provider'
|
||||
import AppProvider from './src/store/Provider'
|
||||
import wrapPageElementWithTransition from './src/helpers/wrapPageElement'
|
||||
|
||||
export const replaceRenderer = ({ bodyComponent, replaceBodyHTMLString }) => {
|
||||
|
@ -2,3 +2,5 @@ import 'jest-dom/extend-expect'
|
||||
|
||||
// this is basically: afterEach(cleanup)
|
||||
import 'react-testing-library/cleanup-after-each'
|
||||
|
||||
import 'jest-canvas-mock'
|
||||
|
@ -71,6 +71,7 @@
|
||||
"eslint-plugin-react": "^7.12.4",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^24.7.1",
|
||||
"jest-canvas-mock": "^2.0.0-beta.1",
|
||||
"jest-dom": "^3.1.3",
|
||||
"ora": "^3.4.0",
|
||||
"prepend": "^1.0.2",
|
||||
|
@ -41,7 +41,7 @@ export default class Vcard extends PureComponent {
|
||||
|
||||
const handleAddressbookClick = e => {
|
||||
e.preventDefault()
|
||||
constructVcard(meta)
|
||||
init(meta)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -60,6 +60,16 @@ export default class Vcard extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export const init = async meta => {
|
||||
const photoSrc = meta.avatar.childImageSharp.resize.src
|
||||
|
||||
// first, convert the avatar to base64, then construct all vCard elements
|
||||
const dataUrl = await toDataURL(photoSrc, 'image/jpeg')
|
||||
const vcard = await constructVcard(dataUrl, meta)
|
||||
|
||||
downloadVcard(vcard, meta)
|
||||
}
|
||||
|
||||
// Construct the download from a blob of the just constructed vCard,
|
||||
// and save it to user's file system
|
||||
export const downloadVcard = (vcard, meta) => {
|
||||
@ -69,60 +79,54 @@ export const downloadVcard = (vcard, meta) => {
|
||||
saveAs(blob, name)
|
||||
}
|
||||
|
||||
export const constructVcard = meta => {
|
||||
export const constructVcard = async (dataUrl, meta) => {
|
||||
const contact = new vCard()
|
||||
const photoSrc = meta.avatar.childImageSharp.resize.src
|
||||
|
||||
// first, convert the avatar to base64, then construct all vCard elements
|
||||
toDataURL(
|
||||
photoSrc,
|
||||
dataUrl => {
|
||||
// stripping this data out of base64 string is required
|
||||
// for vcard to actually display the image for whatever reason
|
||||
const dataUrlCleaned = dataUrl.split('data:image/jpeg;base64,').join('')
|
||||
contact.set('photo', dataUrlCleaned, { encoding: 'b', type: 'JPEG' })
|
||||
contact.set('fn', meta.title)
|
||||
contact.set('title', meta.tagline)
|
||||
contact.set('email', meta.email)
|
||||
contact.set('url', meta.url, { type: 'Portfolio' })
|
||||
contact.add('url', meta.social.Blog, { type: 'Blog' })
|
||||
contact.set('nickname', 'kremalicious')
|
||||
contact.add('x-socialprofile', meta.social.Twitter, { type: 'twitter' })
|
||||
contact.add('x-socialprofile', meta.social.GitHub, { type: 'GitHub' })
|
||||
// stripping this data out of base64 string is required
|
||||
// for vcard to actually display the image for whatever reason
|
||||
// const dataUrlCleaned = dataUrl.split('data:image/jpeg;base64,').join('')
|
||||
// contact.set('photo', dataUrlCleaned, { encoding: 'b', type: 'JPEG' })
|
||||
contact.set('fn', meta.title)
|
||||
contact.set('title', meta.tagline)
|
||||
contact.set('email', meta.email)
|
||||
contact.set('url', meta.url, { type: 'Portfolio' })
|
||||
contact.add('url', meta.social.Blog, { type: 'Blog' })
|
||||
contact.set('nickname', 'kremalicious')
|
||||
contact.add('x-socialprofile', meta.social.Twitter, { type: 'twitter' })
|
||||
contact.add('x-socialprofile', meta.social.GitHub, { type: 'GitHub' })
|
||||
|
||||
const vcard = contact.toString('3.0')
|
||||
const vcard = contact.toString('3.0')
|
||||
|
||||
downloadVcard(vcard, meta)
|
||||
},
|
||||
'image/jpeg'
|
||||
)
|
||||
return vcard
|
||||
}
|
||||
|
||||
// Helper function to create base64 string from avatar image
|
||||
// without the need to read image file from file system
|
||||
export const toDataURL = (src, callback, outputFormat) => {
|
||||
export const toDataURL = async (photoSrc, outputFormat) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'Anonymous'
|
||||
img.src = photoSrc
|
||||
|
||||
img.onload = function() {
|
||||
// yeah, we're gonna create a fake canvas to render the image
|
||||
// and then create a base64 string from the rendered result
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
let dataURL
|
||||
img.onload = () => {}
|
||||
|
||||
canvas.height = this.naturalHeight
|
||||
canvas.width = this.naturalWidth
|
||||
ctx.drawImage(this, 0, 0)
|
||||
dataURL = canvas.toDataURL(outputFormat)
|
||||
callback(dataURL)
|
||||
}
|
||||
// yeah, we're gonna create a fake canvas to render the image
|
||||
// and then create a base64 string from the rendered result
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
let dataURL
|
||||
|
||||
img.src = src
|
||||
canvas.height = img.naturalHeight
|
||||
canvas.width = img.naturalWidth
|
||||
ctx.drawImage(img, 0, 0)
|
||||
dataURL = canvas.toDataURL(outputFormat)
|
||||
|
||||
if (img.complete || img.complete === undefined) {
|
||||
img.src =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='
|
||||
img.src = src
|
||||
}
|
||||
// img.src = photoSrc
|
||||
|
||||
// if (img.complete || img.complete === undefined) {
|
||||
// img.src =
|
||||
// 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='
|
||||
// img.src = photoSrc
|
||||
// }
|
||||
|
||||
return dataURL
|
||||
}
|
||||
|
@ -1,37 +1,36 @@
|
||||
import React from 'react'
|
||||
import { render } from 'react-testing-library'
|
||||
import { StaticQuery } from 'gatsby'
|
||||
import vCard from 'vcf'
|
||||
import Vcard, { constructVcard, downloadVcard, toDataURL } from './Vcard'
|
||||
import Vcard, { constructVcard, toDataURL, init } from './Vcard'
|
||||
import data from '../../../jest/__fixtures__/meta.json'
|
||||
|
||||
describe('Vcard', () => {
|
||||
beforeEach(() => {
|
||||
StaticQuery.mockImplementationOnce(({ render }) => render({ ...data }))
|
||||
|
||||
global.URL.createObjectURL = jest.fn()
|
||||
})
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { container } = render(<Vcard />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('vCard can be constructed', async () => {
|
||||
await constructVcard(data.contentYaml)
|
||||
})
|
||||
|
||||
it('vCard can be downloaded', async () => {
|
||||
const contact = new vCard()
|
||||
const vcard = contact.toString('3.0')
|
||||
|
||||
global.URL.createObjectURL = jest.fn(() => 'details')
|
||||
await downloadVcard(vcard, data.contentYaml)
|
||||
it('combined vCard download process finishes', async () => {
|
||||
await init(data.contentYaml)
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('Base64 from image can be constructed', () => {
|
||||
const photoSrc = data.contentYaml.avatar.childImageSharp.resize.src
|
||||
it('vCard can be constructed', async () => {
|
||||
const vcard = await constructVcard(
|
||||
'data:image/jpeg;base64,00',
|
||||
data.contentYaml
|
||||
)
|
||||
expect(vcard).toBeDefined()
|
||||
})
|
||||
|
||||
toDataURL(photoSrc, () => null, 'image/jpeg')
|
||||
it('Base64 from image can be constructed', async () => {
|
||||
const dataUrl = await toDataURL('hello', 'image/jpeg')
|
||||
expect(dataUrl).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
@ -6,9 +6,7 @@ import data from '../../../jest/__fixtures__/meta.json'
|
||||
|
||||
describe('Availability', () => {
|
||||
it('renders correctly from data file values', () => {
|
||||
useStaticQuery.mockImplementation(() => {
|
||||
return { ...data }
|
||||
})
|
||||
useStaticQuery.mockImplementation(() => ({ ...data }))
|
||||
const { container } = render(<Availability />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
@ -5,9 +5,7 @@ import Networks from './Networks'
|
||||
import data from '../../../jest/__fixtures__/meta.json'
|
||||
|
||||
beforeEach(() => {
|
||||
useStaticQuery.mockImplementationOnce(() => {
|
||||
return { ...data }
|
||||
})
|
||||
useStaticQuery.mockImplementationOnce(() => ({ ...data }))
|
||||
})
|
||||
|
||||
describe('Networks', () => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { render } from 'react-testing-library'
|
||||
import AppProvider from '../../store/provider'
|
||||
import AppProvider from '../../store/Provider'
|
||||
import ThemeSwitch from './ThemeSwitch'
|
||||
|
||||
describe('ThemeSwitch', () => {
|
||||
|
@ -11,9 +11,6 @@ const query = graphql`
|
||||
query {
|
||||
# the package.json file
|
||||
portfolioJson {
|
||||
name
|
||||
homepage
|
||||
repository
|
||||
bugs
|
||||
}
|
||||
|
||||
@ -25,7 +22,7 @@ const query = graphql`
|
||||
}
|
||||
`
|
||||
|
||||
export const FooterMarkup = ({ pkg, meta, year }) => {
|
||||
const FooterMarkup = ({ pkg, meta, year }) => {
|
||||
const classes = classNames('h-card', [styles.footer])
|
||||
|
||||
return (
|
||||
|
22
src/components/organisms/Footer.test.jsx
Normal file
22
src/components/organisms/Footer.test.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { render } from 'react-testing-library'
|
||||
import { StaticQuery, useStaticQuery } from 'gatsby'
|
||||
import Footer from './Footer'
|
||||
import data from '../../../jest/__fixtures__/meta.json'
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
StaticQuery.mockImplementation(({ render }) =>
|
||||
render({
|
||||
...data,
|
||||
portfolioJson: { bugs: '' }
|
||||
})
|
||||
)
|
||||
useStaticQuery.mockImplementation(() => ({ ...data }))
|
||||
})
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { container } = render(<Footer />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
22
src/components/organisms/Header.test.jsx
Normal file
22
src/components/organisms/Header.test.jsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { render } from 'react-testing-library'
|
||||
import { StaticQuery, useStaticQuery } from 'gatsby'
|
||||
import Header from './Header'
|
||||
import { Provider } from '../../store/createContext'
|
||||
import data from '../../../jest/__fixtures__/meta.json'
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
StaticQuery.mockImplementation(({ render }) => render({ ...data }))
|
||||
useStaticQuery.mockImplementation(() => ({ ...data }))
|
||||
})
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { container } = render(
|
||||
<Provider value={{ dark: false, toggleDark: () => null }}>
|
||||
<Header />
|
||||
</Provider>
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
@ -5,9 +5,7 @@ import { useMeta } from './use-meta'
|
||||
import data from '../../jest/__fixtures__/meta.json'
|
||||
|
||||
beforeEach(() => {
|
||||
useStaticQuery.mockImplementationOnce(() => {
|
||||
return { ...data }
|
||||
})
|
||||
useStaticQuery.mockImplementationOnce(() => ({ ...data }))
|
||||
})
|
||||
|
||||
describe('useMeta', () => {
|
||||
|
73
src/store/Provider.jsx
Normal file
73
src/store/Provider.jsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { PureComponent } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Provider } from './createContext'
|
||||
import { getLocationTimes } from '../utils/getLocationTimes'
|
||||
import { getCountry } from '../utils/getCountry'
|
||||
|
||||
export default class AppProvider extends PureComponent {
|
||||
state = {
|
||||
dark: false,
|
||||
toggleDark: () => this.toggleDark,
|
||||
location: null
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.any.isRequired
|
||||
}
|
||||
|
||||
store = typeof localStorage === 'undefined' ? null : localStorage
|
||||
|
||||
mounted = false
|
||||
|
||||
async componentDidMount() {
|
||||
this.mounted = true
|
||||
const location = await getCountry()
|
||||
this.setState({ location })
|
||||
this.checkDark()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false
|
||||
}
|
||||
|
||||
setDark() {
|
||||
this.mounted && this.setState({ dark: true })
|
||||
}
|
||||
|
||||
setLight() {
|
||||
this.mounted && this.setState({ dark: false })
|
||||
}
|
||||
|
||||
darkLocalStorageMode(darkLocalStorage) {
|
||||
darkLocalStorage === 'true' ? this.setDark() : this.setLight()
|
||||
}
|
||||
|
||||
//
|
||||
// All the checks to see if we should go dark or light
|
||||
//
|
||||
async checkTimes() {
|
||||
const { location, dark } = this.state
|
||||
const { sunset, sunrise } = await getLocationTimes(location)
|
||||
const now = new Date().getHours()
|
||||
const weWantItDarkTimes = now >= sunset || now <= sunrise
|
||||
|
||||
!dark && weWantItDarkTimes ? this.setDark() : this.setLight()
|
||||
}
|
||||
|
||||
async checkDark() {
|
||||
const darkLocalStorage = await this.store.getItem('dark')
|
||||
|
||||
darkLocalStorage
|
||||
? this.darkLocalStorageMode(darkLocalStorage)
|
||||
: this.checkTimes()
|
||||
}
|
||||
|
||||
toggleDark = () => {
|
||||
this.setState({ dark: !this.state.dark })
|
||||
this.store && this.store.setItem('dark', !this.state.dark)
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Provider value={this.state}>{this.props.children}</Provider>
|
||||
}
|
||||
}
|
11
src/store/Provider.test.jsx
Normal file
11
src/store/Provider.test.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import { render } from 'react-testing-library'
|
||||
import AppProvider from './Provider.jsx'
|
||||
|
||||
describe('AppProvider', () => {
|
||||
it('renders correctly', () => {
|
||||
const { container } = render(<AppProvider>Hello</AppProvider>)
|
||||
|
||||
expect(container.firstChild.textContent).toBe('Hello')
|
||||
})
|
||||
})
|
@ -1,120 +0,0 @@
|
||||
import React, { PureComponent } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import SunCalc from 'suncalc'
|
||||
import { Provider } from './createContext'
|
||||
import countrycodes from './countrycode-latlong.json'
|
||||
|
||||
export default class AppProvider extends PureComponent {
|
||||
state = {
|
||||
dark: false,
|
||||
toggleDark: () => this.toggleDark,
|
||||
location: null
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.any.isRequired
|
||||
}
|
||||
|
||||
store = typeof localStorage === 'undefined' ? null : localStorage
|
||||
|
||||
//
|
||||
// Get user location from Cloudflare's geo location HTTP header
|
||||
//
|
||||
getCountry = async () => {
|
||||
let trace = []
|
||||
|
||||
try {
|
||||
const data = await fetch('/cdn-cgi/trace?no-cache=1')
|
||||
const text = await data.text()
|
||||
const lines = text.split('\n')
|
||||
|
||||
let keyValue
|
||||
|
||||
lines.forEach(line => {
|
||||
keyValue = line.split('=')
|
||||
trace[keyValue[0]] = decodeURIComponent(keyValue[1] || '')
|
||||
|
||||
if (keyValue[0] === 'loc' && trace['loc'] !== 'XX') {
|
||||
this.setState({ location: trace['loc'] })
|
||||
} else {
|
||||
return
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
return null // fail silently
|
||||
}
|
||||
}
|
||||
|
||||
setDark() {
|
||||
this.setState({ dark: true })
|
||||
}
|
||||
|
||||
setLight() {
|
||||
this.setState({ dark: false })
|
||||
}
|
||||
|
||||
darkLocalStorageMode(darkLocalStorage) {
|
||||
if (darkLocalStorage === 'true') {
|
||||
this.setDark()
|
||||
} else {
|
||||
this.setLight()
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// All the checks to see if we should go dark or light
|
||||
//
|
||||
darkMode() {
|
||||
const now = new Date().getHours()
|
||||
const { location } = this.state
|
||||
// fallback times, in hours
|
||||
let sunrise = 7
|
||||
let sunset = 19
|
||||
|
||||
// times based on detected country code
|
||||
if (location && location !== 'XX' && location !== 'T1') {
|
||||
const country = this.state.location.toLowerCase()
|
||||
const times = SunCalc.getTimes(
|
||||
new Date(),
|
||||
countrycodes[country][0],
|
||||
countrycodes[country][1]
|
||||
)
|
||||
sunrise = times.sunrise.getHours()
|
||||
sunset = times.sunset.getHours()
|
||||
}
|
||||
|
||||
if (!this.state.dark && (now >= sunset || now <= sunrise)) {
|
||||
this.setDark()
|
||||
} else {
|
||||
this.setLight()
|
||||
}
|
||||
}
|
||||
|
||||
checkDark() {
|
||||
const darkLocalStorage = this.store.getItem('dark')
|
||||
|
||||
if (darkLocalStorage) {
|
||||
this.darkLocalStorageMode(darkLocalStorage)
|
||||
} else {
|
||||
this.darkMode()
|
||||
}
|
||||
}
|
||||
|
||||
toggleDark = () => {
|
||||
this.setState({ dark: !this.state.dark })
|
||||
|
||||
if (this.store) {
|
||||
this.store.setItem('dark', !this.state.dark)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getCountry().then(() => {
|
||||
this.checkDark()
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Provider value={this.state}>{this.props.children}</Provider>
|
||||
}
|
||||
}
|
28
src/utils/getCountry.js
Normal file
28
src/utils/getCountry.js
Normal file
@ -0,0 +1,28 @@
|
||||
//
|
||||
// Get user location from Cloudflare's geo location HTTP header
|
||||
//
|
||||
// @returns country: string
|
||||
//
|
||||
export const getCountry = async () => {
|
||||
try {
|
||||
const data = await fetch('/cdn-cgi/trace?no-cache=1')
|
||||
const text = await data.text().replace(/ /g, '')
|
||||
const lines = text.split('\n')
|
||||
|
||||
let keyValue
|
||||
let trace = []
|
||||
|
||||
await lines.forEach(line => {
|
||||
keyValue = line.split('=')
|
||||
trace[keyValue[0]] = decodeURIComponent(keyValue[1] || '')
|
||||
})
|
||||
|
||||
const country = trace['loc']
|
||||
|
||||
if (country && country !== 'XX') {
|
||||
return country
|
||||
}
|
||||
} catch (error) {
|
||||
return null // fail silently
|
||||
}
|
||||
}
|
27
src/utils/getCountry.test.js
Normal file
27
src/utils/getCountry.test.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { getCountry } from './getCountry'
|
||||
|
||||
const responseMock = 'loc=DE'
|
||||
|
||||
const mockFetch = data =>
|
||||
jest.fn().mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
Id: '123',
|
||||
text: () => data
|
||||
// json: () => data
|
||||
})
|
||||
)
|
||||
|
||||
describe('getCountry', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch(responseMock)
|
||||
})
|
||||
|
||||
it('fetches and returns correct value', async () => {
|
||||
const country = await getCountry()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(global.fetch).toHaveBeenCalledWith('/cdn-cgi/trace?no-cache=1')
|
||||
expect(country).toBe('DE')
|
||||
})
|
||||
})
|
25
src/utils/getLocationTimes.js
Normal file
25
src/utils/getLocationTimes.js
Normal file
@ -0,0 +1,25 @@
|
||||
import SunCalc from 'suncalc'
|
||||
import countrycodes from './countrycode-latlong.json'
|
||||
|
||||
//
|
||||
// All the checks to see if we should go dark or light
|
||||
//
|
||||
export const getLocationTimes = location => {
|
||||
// fallback times, in hours
|
||||
let sunrise = 7
|
||||
let sunset = 19
|
||||
|
||||
// times based on detected country code
|
||||
if (location && location !== 'XX' && location !== 'T1') {
|
||||
const country = location.toLowerCase()
|
||||
const times = SunCalc.getTimes(
|
||||
new Date(),
|
||||
countrycodes[country][0],
|
||||
countrycodes[country][1]
|
||||
)
|
||||
sunrise = times.sunrise.getHours()
|
||||
sunset = times.sunset.getHours()
|
||||
}
|
||||
|
||||
return { sunrise, sunset }
|
||||
}
|
9
src/utils/getLocationTimes.test.js
Normal file
9
src/utils/getLocationTimes.test.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { getLocationTimes } from './getLocationTimes'
|
||||
|
||||
describe('getLocationTimes', () => {
|
||||
it('returns values', async () => {
|
||||
const { sunset, sunrise } = await getLocationTimes('DE')
|
||||
expect(sunset).toBeDefined()
|
||||
expect(sunrise).toBeDefined()
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user