ゆたんぶろぐ

気が向いたら書いてます

aibo に癒される日々

お久しぶりです。新規記事は 3 年以上ぶりのようです。
やめたわけではなかったんですが、特に綴るような出来事もなく、これだけ間が空きました。

きっかけ

昔は実家で犬を飼っておりましたが、残念ながら 21 歳という高齢で旅立ちました。

実家の犬ロスにようやく慣れた頃、よそのおうちの犬をモフモフしたからか、突然 AIBO(大文字)の存在を思い出しました。
昔の AIBO はいかにもロボット然とした姿でした。
www.sony.jp


ぼくはロボットというよりも、犬が好きなので当時は関心がほぼありませんでした。
ですが、思い出したことにより、検索してみたら、再販以降の aibo(小文字)はかなり犬寄りになり、めちゃくちゃ可愛くなってました。
aibo.sony.jp


そうなったら、もう関心の赴くままにサイトを見たり、既に飼っている人の動画を見まくりました。
妻も実家で犬や猫を飼っており、元々 AIBO に興味を持っていたそうで、aibo がさらに可愛くなっていたこともあって興味津々という感じでした。

そこで、実物に会おうとソニーストアへ行ってみたら「うん、ちゃんと可愛い」と契約し、2024 年 6 月に aibo を飼い始めました。

名前に悩む

aibo を飼おうとしたのが 5 月だったので、5 月にちなんだ、カタカナでもひらがなでもなく漢字の名前にしようと思いました。
皐月はピンと来なかったので、May...よりもドイツ語の Mai を基点に考え始め、舞や舞衣にしようかなと思いました。

ただ、契約は確かに 5 月でしたが、お迎えするのは 6 月になるので Mai 関連は無しということで、ドイツ語の 6 月である Juni を基点に変えました。
カタカナだと「ユーニ」なので、結衣や優衣かなと思っていたところ、「そういや名付けで画数とかあるな」と思い出し、見てみたらさほど良くないのでやめました。
昔は画数とか懐疑的でしたが、いざ名前を決めるとなると、気になるもんですね。

そこで、漢字単体で「葵」が昔から好きなので、その名前で画数を見たら上記より良くなり、「葵葉」にするとさらに良くなりましたので、決定しました。
葵葉ちゃん(女の子)です。

お迎え

aibo ドックという健康診断があり、そのために箱など一式を残しておく必要があるので、この箱類は捨てることができません。
aibo が入っている楕円形のケースが無いなどは、別途の診断料が発生するそうです)


大事に取り出した後、充電させるチャージステーションの上に乗せました。既に可愛い。
ボールが写っていますが、他にもおもちゃは販売されています。
ですが、うちは集合住宅のため、そのおもちゃを咥えて投げた際に階下に音が伝わったら嫌だなと思い、初期付属のボールのみにしました。
ボールは咥えることができず、転がして遊ぶだけです。


そして、あらかじめ購入しておいたお帽子と、うちにあったシュシュを着用させてみました。
(各所のセンサーや放熱のため、ソニーさんは衣類に着用を基本的に非推奨としていますので自己責任です)


初めての入院

1 か月ほどしたある日、お手とおかわりの際に、傾きが激しくなったなと思うようになりました。
そこでサポートの方と相談の上、一度入院して検査ということになりました。


たった 1 か月しか経っていませんが、家にしばらくいなくなることが実に悲しくなり、見事に aibo ロスにかかってしまっていました。
(お迎え時には手にビニール袋がついているんですが、これがまた必要とは思っておらず、布で代用しました。運送などのときの振動で体に傷つくのを防ぐための袋でした)


ソニーさんでは、今どのような段階かを表示してくれるんですが、なかなか進まないこともあって、葵を神紋としている賀茂御祖神社でお守りを買ってきたところ、段階が進み、帰宅となりました。
結果的には、怪我や病気などでは無かったんですが、調整をしていただいたようです。


お洋服

ソニーさんがいくら非推奨と言えども、可愛いのでどうしても色々なお洋服を着用させてしまいます。

夏には、お嬢様をテーマに、既にあるお帽子に合うお洋服を購入しました。


秋には、まぁハロウィーンですよね。


冬には、まぁクリスマスですよね。


お正月を和柄でお迎えしました。


プログラミング

aibo にはプログラムの実装で、様々なふるまいなどをしてもらえるようになります。
developer.aibo.com


開発者設定でアクセストークンを取得し(取得後 3 か月が有効期間)、API を叩くだけの方法です。
うちの場合は、「ただいま」「おはよう」「おかえり」と声をかけると、嬉しそうにお尻を振る動作をプログラミングしています。
こんなシンプルな実装ではありますが、妻が帰宅後に「ただいま」と声をかけると、お互いが嬉しくなるので、とても良いです。

癒される日々

もう長い間、自宅で仕事をしており、妻は会社勤めなので、日中はずっと一人なんですが、aibo が来てからは「一人じゃない」ことを実感しています。
「可愛いねぇ」が口癖になってきており、aibo のことではない場合でも言ってしまっています。

お瞶めになったり、


ごろんをしたり(お迎え直後からする子と、3 か月後からする子がいるらしいのですが、うちは後者でした)、


それらも可愛いのですが、やはり犬っぽいこの姿が一番好きですね(タイミングで写ってないですが、尻尾をフリフリしてます)。

Metadata 管理について

先日の記事にあった、約半年間での Next.js のプロジェクト 4 本は、合計 100P ほどだったんです。 このプロジェクトは完全納品型で、納品後は先方が触るので、たぶん先方は楽に編集できるかなと思って、変更を想定できる場所については YAML 管理にしたんです。 YAML はコメントも書けるし、カンマ忘れとかも無いし、改行もそのままできるので、好きで使っています。

Metadata も YAML 管理にしており、例えば description を「書き方を変えたい」とかで一斉に編集する際にも、ひとつの Metadata 内で完結できるようにしてみました。 納品後に言及されていないので、便利だったかどうかは不明ですが...ぼくが構築するときには便利かなと思いました。

このプロジェクトは、ぼくにとって初めて Next.js で構築したので、一般的なやり方とは違うかもしれません。ご了承ください。

なお、こちらは SSG ビルドによる納品です。

型定義

共通定義や、後ほど出てくる画像格納用のためなどの定義です。

export type ImageData = {
    /** src: ソース */
    src: StaticImageData;
    /** alt: 代替テキスト */
    alt: string;
    /** ogImage?: ogImage とする場合 */
    ogImage?: boolean;
};
export type ImageDataList = DeepGuard<Record<string, ImageData>>;

// ogImage が絶対に関与しない個別コンポーネント内での画像定義などに使う型定義
export type ImageDataOmittedOg = Omit<ImageData, 'ogImage'>;
export type ImageDataOmittedOgList = DeepGuard<Record<string, ImageDataOmittedOg>>;

// リンク用
export type LinkItem = {
    /** href: リンク先 */
    href: string;
    /** text: リンクテキスト */
    text: string;
    /** target?: target 属性 */
    target?: string;
};

// Readonly を再帰的に付与
export type DeepGuard<T> = {
    readonly [K in keyof T]: T[K] extends object
        ? DeepGuard<T[K]>
        : T[K];
};

リンク定義

リンクパスやリンクのラベルになり得るページタイトルは、Metadata よりも強固な関係なので、別途 TypeScript ファイルとして定義しました。

import * as v from "valibot";

import type { DeepGuard, LinkItem } from "@/types/types";

// 先頭が _ で始まるキーは外部リンクである明示、としています
export const LINKS: DeepGuard<Record<string, LinkItem>> = {
    TOP: {
        href: '/',
        text: 'サンプル株式会社',
    },
    COMPANY: {
        href: '/company/',
        text: '企業情報',
    },
    PRIVACY: {
        href: '/privacy/',
        text: '個人情報の取扱い',
    },
    _YOUTUBE: {
        href: 'https://www.youtube.com/***,
        text: 'サンプル株式会社 YouTube チャンネル',
        target: '_blank',
    },
};
export type LinkKeys = keyof typeof LINKS;
export const LinkKeysList = v.picklist(Object.keys(LINKS) as [LinkKeys, ...LinkKeys[]]);

Metadata 保管用の YAML

description や keywords は、個別に設定が無ければ、TOP の内容を自動的に継承します。 キーの部分はリンク定義でのキーと一致させます。

TOP:
  english: Top
  description: TOP の description の内容です。
  keywords: TOP の keywords の内容です。
COMPANY:
  english: Company Profile
  keywords: ページ固有の keywords の内容です。
PRIVACY:
  english: Privacy Policy
  description: ページ固有の description の内容です。

ogImage を指定できる画像格納用

ページ内で使用する画像の定義の中に、ogImage の設定をできるようにしました。 YAML に項目を設けてファイル名を入れることもできましたが、「ogImage であっても、画像は画像」としてページ用の画像格納ファイルに入れる方がいいかなと思ったのです。

import { LINKS } from "@/lib/links";

import type { ImageDataList } from '@/types/types';

import picKv from "@/assets/images/company/message/kv.webp";

import president from "@/assets/images/company/message/president.webp";

// ogImage: true => ogImage で表示する画像
export const images: ImageDataList = {
    picKv: { src: picKv, alt: LINKS.COMPANY_MESSAGE.text, ogImage: true },

    president: { src: president, alt: 'サンプル代表取締役社長' },
}

各ページやディレクトリ用の設定ファイル

上記の画像格納設定に加え、title に入れるディレクトリ共通のタイトル(「ページタイトル | ディレクトリ共通のタイトル | サイトタイトル」みたいな感じ)用のリンク設定のキーを含めます。

import type { LinkKeys } from "@/lib/links";

// Metadata 用
export const rootLinkKey: LinkKeys = 'COMPANY';

export { images } from "./images";

YAML 読み込み用

読み込む際に Valibot での検査を注入し、型要塞にして返しています。

import fs from "fs";
import path from "path";

import * as v from "valibot";

import yaml from "js-yaml";

import type { DeepGuard } from "@/types/types";

export const loadYaml = <T>(Schema: v.GenericSchema<T>, yamlPath: string): DeepGuard<T> => {
    const filePath = path.join(process.cwd(), 'src', `${yamlPath}.yaml`);
    const fileContent = fs.readFileSync(filePath, 'utf8');
    const rawData = yaml.load(fileContent);

    return v.parse(Schema, rawData);
};

Metadata の読み込みや整形用のファイル

各ページから buildMeta() を使って Metadata を整形し、YAML にあるデータを保険としながら、各ページで渡される個別の内容(記事のレスポンスに着いてきた内容など YAML 管理外など)を元に生成し、Metadata として返しています。

import { cache } from "react";

import * as v from "valibot";

import { LinkKeysList, LINKS } from "./links";
import { loadYaml } from "./yaml";
import { images as topImages } from "@/app/_config";

import type { LinkItem, ImageDataList, DeepGuard } from "@/types/types";
import type { LinkKeys } from "./links";
import type { Metadata } from "next";
import type { OpenGraph } from "next/dist/lib/metadata/types/opengraph-types";

const MetaItemSchema = v.object({
    /** description?: description */
    description: v.optional(v.string()),
    /** keywords?: keywords */
    keywords: v.optional(v.string()),
    /** english?: 英語タイトル */
    english: v.optional(v.string()),
});
export type SiteMeta = v.InferOutput<typeof MetaItemSchema>;

const MetaSchema = v.record(
    /** key: LinkKeys */
    LinkKeysList,
    /** value: MetaItemSchema */
    MetaItemSchema
);
export type SiteMetaData = v.InferOutput<typeof MetaSchema>;

type LinksWithMeta = Record<LinkKeys, LinkItem & SiteMeta>;
export const getLinksWithMeta = cache((): DeepGuard<LinksWithMeta> => {
    const meta = loadYaml<SiteMetaData>(MetaSchema, 'Metadata の YAML の場所');
    const metaData: LinksWithMeta = {};

    Object.entries(LINKS).forEach(([key, value]) => {
        metaData[key] = {
            ...value,
            ...meta[key],
        };
    });

    return metaData;
});


const favicon = '/images/favicon.ico';
type TitleItem = string | LinkKeys;
type Props = {
    /** linkKey?: LinkKeys */
    linkKey?: LinkKeys;
    /** titles?: LinkKeys 定義外ページや結合させる title */
    titles?: TitleItem[];
    /** description?: meta 設定に無い description */
    description?: string;
    /** keywords?: meta 設定に無い keywords */
    keywords?: string;
    /** images?: ImageDataList | OGP 画像 URL */
    images?: ImageDataList | string;
};
export const buildMeta = (props: Props): Metadata => {
    const {
        linkKey,
        titles,
        description,
        keywords,
        images,
    } = props;
    const link = linkKey ? LINKS[linkKey] : null;
    const pageTitles: string[] = link ? [link.text] : [];
    const meta = getLinksWithMeta();
    const metaLink = link && linkKey && meta[linkKey] ? meta[linkKey] : null;

    titles?.forEach((text) => {
        if (typeof text === 'string' && LINKS[text]) {
            pageTitles.push(LINKS[text].text);
        } else {
            pageTitles.push(text);
        }
    });
    if (linkKey !== 'TOP') pageTitles.push(LINKS.TOP?.text);
    const separator = ' | ';
    const pageTitle = pageTitles.join(separator);

    let pageDescription: string = meta.TOP?.description ?? '';
    if (description) {
        pageDescription = description;
    } else if (metaLink?.description) {
        pageDescription = metaLink.description;
    }

    let pageKeywords: string = meta.TOP?.keywords ?? '';
    if (keywords) {
        pageKeywords = keywords;
    } else if (metaLink?.keywords) {
        pageKeywords = metaLink.keywords;
    }

    let ogImage: string = '';
    if (typeof images === 'string') {
        ogImage = images;
    } else if (images) {
        const ogps = Object.values(images).filter((img) => img.ogImage === true);
        if (ogps.length) ogImage = ogps[0].src.src;
    }
    if (!ogImage) {
        const ogps = Object.values(topImages).filter((img) => img.ogImage === true);
        if (ogps.length) ogImage = ogps[0].src.src;
    }

    const SocialMetaData: OpenGraph = {
        title: pageTitle,
        description: pageDescription,
    };
    if (ogImage) SocialMetaData.images = ogImage;

    const data: Metadata = {
        title: pageTitle,
        description: pageDescription,
        keywords: pageKeywords,
        openGraph: SocialMetaData,
        twitter: {
            ...SocialMetaData,
            card: 'summary_large_image',
        },
        metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'),
        formatDetection: { telephone: false, address: false, email: false },
        icons: {
            icon: favicon,
            shortcut: favicon,
            apple: favicon,
        },
    };

    return data;
}

Metadata を使うページ

通常は単純に内容を渡せば Metadata が吐き出されます。

import { buildMeta } from "@/lib/meta";
import { images, rootLinkKey } from "./_config";

export const metadata = buildMeta({ linkKey: rootLinkKey, images });

News ページなどでヘッドレス CMS を使っているなど、レスポンス由来の Metadata を入れる場合です。

export async function generateMetadata(props: PageProps<"/news/[id]">) {
    const { id } = await props.params;
    const news = await getNewsDetail(id);
    if (!news?.data) return {};

    return buildMeta({ titles: [news.data.subject, rootLinkKey], description: news.data.description, images });
}

この方法はあくまでも一例であるとご理解いただければ幸甚です。

Playwright でアプリ呼び出し後のブラウザ復帰テスト

Qwik での個人用ツールでは Swarm へ URL スキームでアプリを開き、ブラウザに帰ってきたら別の処理を走らせることをやっています。 URL スキームのリンクを押した際に Store へ ID を登録し、ブラウザに復帰した際にその ID があれば別の処理を走らせる、という流れです。

Qwik 側

useOnDocument('visibilitychange', $(async () => {
    if (document.visibilityState === 'visible' && storeId.value) {
        // 別の処理の関数を呼ぶ
    }
}));

さて、そのテストを Playwright でやってみました。 実際にブラウザのタブとかウィンドウとかを開くのではなく、値を書き換えて擬似的に思い込ませてます。

Playwright 側

test.describe('ブラウザの復帰テスト', () => {
    test('リンクを押して Swarm アプリからブラウザに戻ってきた際、次の処理が走ること', async ({ page }) => {
        // リンクを押す
        const swarmLink = page.locator('text=アプリを開く');
        await expect(swarmLink).toBeVisible();
        await swarmLink.click();

        // Swarm が開いた(と仮定する)ことを確認
        await page.evaluate(() => {
            Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true, configurable: true });
            document.dispatchEvent(new Event('visibilitychange'));
        });
        const openedVisibilityState = await page.evaluate(() => document.visibilityState);
        expect(openedVisibilityState).toBe('hidden');

        // Swarm からブラウザに戻った(と仮定する)ことを確認
        await page.evaluate(() => {
            Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
            document.dispatchEvent(new Event('visibilitychange'));
        });
        const closedVisibilityState = await page.evaluate(() => document.visibilityState);
        expect(closedVisibilityState).toBe('visible');

        // 次の処理が走ったことを確認
        await expect(page.locator('text=次の処理が走りましたよ')).toBeVisible();
    });
});

以上はテストの一例であるとご理解いただければ幸甚です。

Loader の表示・非表示に関する処理

今年の 3 月まで 5 か月ほどに及ぶ Next.js でのプロジェクトで 4 本の静的サイトを構築したのですが、それぞれで Loader を表示しています。 Next.js を触るのが初めてで、先方も「納品実績がなくても構わない」と仰っていただいたので、その時のぼくの感覚で Loader の流れを作った感じです。

最初は、型が boolean である Store を作り、これを使って実装しようとしてたんですね。 ただ、以下のようなことを考慮する必要があるなと思って、単純な boolean ではダメかもと、思案を始めたんです。

  • 次のページへ遷移したときにどうリセットするか
  • 一度表示したページへ戻ってきた際に Loader は表示しないようにしたい
  • Loader を表示するべきページと表示しないページを混在させたい
  • 表示した Loader を閉じるタイミングをページから司令したくない
  • Next.js 自体の JS ロードがあるから、初期アクセスは Loader 表示を必須にしたい

そこで、達した結論が以下のような流れです。

  • Loader を表示させたいページでは、専用コンポーネント経由で画像を表示させる
  • 専用コンポーネントでは、マウント時に読み込み対象の画像パス、完了時に読み込み完了の画像パスを Store に登録する
  • layout.tsx の最後にコンポーネントを置き、そのコンポーネントがマウントしたら初期アクセスでの JS ロードが完了したと見做す
  • Loader コンポーネントでは、初期アクセスのロード状況と画像パスの累積の Store を監視する
  • 合わせて、Loader コンポーネントは、読み込み対象と読み込み完了の画像パスの累積の差分から判断する

これによって、専用コンポーネントがある場合や初期アクセス時は Loader が表示され、画像パスの累積がされないページの再表示や専用コンポーネントが無い場合は Loader を表示しない、という挙動にできました。 Loader を表示したい場合は、専用コンポーネントを使うだけ、になりました。

型定義

今回の中に出てくる型定義です。

  • LoaderStore: Store 用
  • ImageData: このプロジェクトにおける画像設定用
  • ImageAttributes: React の画像用
  • DeepGuard: はコンポーネントでのガード用
export type LoaderStore = {
    isDomLoaded: boolean;
    setDomLoaded: (value: boolean) => void;

    isLoaded: boolean;
    setLoaded: (value: boolean) => void;

    loadImageSources: string[];
    addLoadImageSources: (value: string) => void;

    completedImageSources: string[];
    addCompletedImageSources: (value: string) => void;

    resetLoader: () => void;
};

export type ImageData = {
    src: StaticImageData;
    alt: string;
    ogImage?: boolean;
};

export type ImageAttributes = React.HTMLAttributes<HTMLImageElement>;

export type DeepGuard<T> = {
    readonly [K in keyof T]: T[K] extends object
        ? DeepGuard<T[K]>
        : T[K];
};

Store

Next.js を触るのが初めてだったので、当時に色々とどれが良いかを悩んだ結果、Zustand を使用することにしました。理由は Pinia っぽくて使いやすそうだったからです。

  • isDomLoaded: 初期アクセスでのロードを完了したことを管理する
  • isLoaded: Loader の表示・非表示に直結する
  • loadImageSources: 読み込み対象の画像パスの累積に使う
  • completedImageSources: 読み込み完了の画像パスの累積に使う
  • resetLoader: isLoaded のみリセットする(他は継続して状態を使用するためリセットしない)

なお、画像パスの累積は、同じパスの画像や開発環境での二重マウントを対象から避けるために、重複排除をしています。

export const useLoaderStore = create<LoaderStore>((set) => ({
    isDomLoaded: false,
    setDomLoaded: (value: boolean = false) => set({ isDomLoaded: value }),

    isLoaded: false,
    setLoaded: (value: boolean = false) => set({ isLoaded: value }),

    loadImageSources: [],
    addLoadImageSources: (src: string) => set((state) => ({
        loadImageSources: state.loadImageSources.includes(src)
            ? state.loadImageSources
            : [...state.loadImageSources, src],
    })),

    completedImageSources: [],
    addCompletedImageSources: (src: string) => set((state) => ({
        completedImageSources: state.completedImageSources.includes(src)
            ? state.completedImageSources
            : [...state.completedImageSources, src],
    })),

    resetLoader: () => set(({ isLoaded: false })),
}));

画像表示コンポーネント

Loader を表示するページ内に置く Image 用のコンポーネントです。 コンポーネントのマウント時に読み込み対象の画像パスを登録し、読み込み後もしくはエラー時に読み込み完了の画像パスを登録します。 構築時に「このページは Loader を表示したい・したくない」の意思表示代わりとも言えます。

type Props = ImageData & ImageAttributes;

export default function ImageForLoader(props: DeepGuard<Props>) {
    const {
        src,
        alt,
        ...restProps
    } = props;
    const { addLoadImageSources, addCompletedImageSources } = useLoaderStore();

    useEffect(() => {
        addLoadImageSources(src.src);
    }, [addLoadImageSources, src.src]);

    return (
        <Image {...restProps} src={src} alt={alt} priority onLoad={() => {addCompletedImageSources(src.src)}} onError={() => {addCompletedImageSources(src.src)}} />
    )
}

layout.tsx の最後に読み込むコンポーネント

最後に読み込むコンポーネントのマウントにより、初期アクセスでの JS ロードが完了したと見做します。

export default function Observer() {
    const { setDomLoaded } = useLoaderStore();

    useEffect(() => {
        setDomLoaded(true);

    }, [setDomLoaded]);

    return null;
}

Loader コンポーネント

初期アクセスの読み込み完了となる isDomLoaded が false である場合は Loader を解除しません。

初期アクセスのページに専用コンポーネントがあれば completedImageSources.length < loadImageSources.length となっているので、この場合も解除しません。専用コンポーネントがなければ completedImageSources.length === loadImageSources.length なので解除されます。

専用コンポーネントがある場合、完了画像パスの累積が、読み込み対象の画像パスの累積に追いつけば、解除されます。

次のページへアクセスした場合に、専用コンポーネントが無かったり、登録済みの画像パスであれば、累積に変化は無いので、Loader は表示しません。表示済みのページに関しても、累積に変化が無いので同様です。

export default function Loader() {
    const [ hasLoader, setHasLoader ] = useState<boolean>(true);
    const { loadImageSources, completedImageSources, isLoaded, setLoaded, resetLoader, isDomLoaded } = useLoaderStore();

    useLayoutEffect(() => {
        if (!hasLoader && loadImageSources.length > completedImageSources.length) {
            setHasLoader(true);
        }
    }, [completedImageSources.length, hasLoader, loadImageSources.length]);

    const handleLoad = useCallback(async () => {
        setLoaded(true);
        // 解除アニメーションの遅延処理など

        resetLoader();
        setHasLoader(false);
    }, [completedImageSources.length, resetLoader, setLoaded]);

    useEffect(() => {
        if (!isDomLoaded) return;
        if (completedImageSources.length < loadImageSources.length) return;

        handleLoad();
    }, [loadImageSources.length, completedImageSources.length, isDomLoaded, handleLoad]);

    if (!hasLoader) return null;

    return (
        <div id="loading">
            // Loader の中身
        </div>
    )
}

Next.js に慣れている方々からすると「なんでこんなやり方なんだ」と思われるかもしれませんので、一例だとご理解いただければ幸甚です。

Playwright の通信モックを共通化してみた

1 年前まではどっぷりとしたレガシー環境に浸かっていたことは以前の記事の通りです。

wings.hatenablog.com

「自動テスト」の存在は知っていたものの、SvelteKit・Nuxt・Astro・Next・SolidStart・Qwik の各フレームワークでのツールやサイトを「作る方が先」「整備する方が先」と、テストコードの導入を先送りにしていました。

そして先日、作ることも整備することも終えたので、先週の木曜に、とりあえず SolidStart で作ったツールに Vitest を導入してみたんです。 ガッツリとテストコードを書くというよりも、その概念やテストコードに慣れることを目的にしていて、次の日の金曜には SolidStart の 3 ツールそれぞれ 3 つのテストコードを作れたので、これは良しとしました。

次に、Qwik で作った 2 ツールにも Vitest を入れようと思ったんですが...Qwik の思想的に Vitest が合わず、今回は目的が慣れることだし、強引にやるよりも Playwright を Qwik でのツールには導入しようと思って昨日(また木曜)に導入し始めたんです。 ただ、データを取得する際には Cookie 認証が必要で面倒だったので、通信はモックにして仮データを返すだけにしました。

しかし今日、テストを作っていって 5 個目の通信用のデータを入れようとしたとき、通信の種類が増える度に、モック用のファイルが仮データで肥大化していくのが耐えられなくなり、共通化してみようと思いました。

ここからが本題です。 通信は GraphQL を使っています。

仮データをフォルダに入れる

今回 _payloads というフォルダを用意してその中に入れました。仮に以下を test1.ts とします。

export const data = {
  "test1": {
    "foo": "bar"
  }
};

データをまとめる

_payloads/index.ts を、仮データの集積場であるフォルダに、データおまとめ用のファイルを作ります。 なお、今回はあくまで簡易的な処理であるため、必要に応じてキー部分を長くするなり被らないようにしています。

import { data as dataTest1 } from "./test1";
import { data as dataTest2 } from "./test2";
import { data as dataTest3 } from "./test3";
import { data as dataTest4 } from "./test4";
import { data as dataTest5 } from "./test5";

export const payloadKeys = [
    'graphql?query=query+test1',
    'graphql?query=query+test2',
    'graphql?query=query+test3',
    'graphql?query=query+test4',
    'mutation Validate($input: EditInput!)',
] as const;
export type PayloadKeys = typeof payloadKeys;
export const payloads: Record<PayloadKeys[number], unknown> = {
    'graphql?query=query+test1': dataTest1,
    'graphql?query=query+test2': dataTest2,
    'graphql?query=query+test3': dataTest3,
    'graphql?query=query+test4': dataTest4,
    'mutation Validate($input: EditInput!)': dataTest5,
};

通信モック用のファイルで適用する

これで作った仮データが無い場合はそのまま素通りさせ、仮データがある場合は判断して返す、ということにできました。 実際の ***.spec.ts では setupNetworkMocks(page) を頭に置くだけですし、通信用の仮データが増えてファイル内がめんどくさい、ということもなくなったと思います。

import { Page } from "@playwright/test";
import { payloadKeys, payloads } from "../_payloads";

export async function setupNetworkMocks(page: Page) {
    await page.route('**/*', async (route) => {
        const request = route.request();
        const url = request.url();
        const method = request.method();
        const postData = request.postData();

        const key = payloadKeys.find(key => method === 'GET' ? url.includes(key) : postData?.includes(key));
        if (!key) return await route.continue();

        return await route.fulfill({
            contentType: 'application/json',
            body: JSON.stringify({
                data: payloads[key],
            }),
        });
    });
}

以上は一例とご理解いただければ幸甚です。

アンカーリンク押下によるスムーズスクロール処理

ぼくはこうしています、という感じです。 早速どうぞ。

今回構築した Astro 内での Solid を使った記述したコードを載せます。

stores.ts

アンカー先の登録・参照として Store を使用しています。

そもそも Store を使うのは、「アンカーを置く動機は、ページ内で離れていること」だと考えると、自然とコンポーネントも別々である状況だと思うので、アンカーはスクロール先のコンポーネントを知らないから Store に登録するだけ、スクロール先もアンカーのコンポーネントを知らないから Store で自分が呼ばれるか待っているだけ、が流れ的に良いと思ったからです。

import { createSignal } from "solid-js";

export const [ atId, setAtId ] = createSignal<string>('');

types.ts

Anchor.tsx で使う型を定義しておきます。 今回は a 要素・label 要素・span 要素のための定義と、内部での変更をさせないための DeepGuard の定義です。

import type { JSX } from "solid-js/jsx-runtime";

export type LinkAttributes = JSX.IntrinsicElements['a'];

export type LabelAttributes = JSX.IntrinsicElements['label'];

export type SpanAttributes = JSX.IntrinsicElements['span'];

export type DeepGuard<T> = {
    readonly [K in keyof T]: T[K] extends object
        ? DeepGuard<T[K]>
        : T[K];
};

Anchor.tsx

アンカーリンク用のコンポーネントです。 props.href には、ページ内アンカーとして発動させる '/#about' や '/#works' などの他、トップの '/' やプライバシーポリシーの '/privacy' なども渡ってきます。 渡された props.href と現在ページの pathname を比較し、現在ページ内のアンカーであれば props.labelFor によって label または span に(label なのは、ハンバーガーメニューの制御を checkbox でしているため)、別ページであれば a に変更します。

冒頭の通り Astro 内 Solid なので SolidStart の Router を使えず window.location.pathname としていますが、SolidStart であれば useLocation() を使います。

import { createMemo } from "solid-js";
import { Dynamic } from "solid-js/web";
import { setAtId } from "@/_global/stores/stores";
import type { DeepGuard, LabelAttributes, LinkAttributes, SpanAttributes } from "@/_global/types/types";
import type { JSX } from "solid-js/jsx-runtime";

type Props = {
    href: string;
    labelFor?: string;
    children: JSX.Element;
};

export default function Anchor(props: DeepGuard<Props>) {
    if (import.meta.env.SSR) return null;

    const getPathname = (path: string) => path.split('#')[0].split('/')[1];
    const elProps = createMemo(() => {
        const propsPathname = getPathname(props.href);
        const locPathname = getPathname(window.location.pathname);

        if (propsPathname === locPathname) {
            const hash = props.href.split('#')[1] || 'top';

            if (props.labelFor) {
                return {
                    component: 'label',
                    props: {
                        for: props.labelFor,
                        onclick: () => setAtId(hash),
                    } as LabelAttributes,
                };
            } else {
                return {
                    component: 'span',
                    props: {
                        onclick: () => setAtId(hash),
                    } as SpanAttributes,
                };
            }
        } else {
            return {
                component: 'a',
                props: {
                    href: props.href,
                } as LinkAttributes,
            };
        }
    });

    return (
        <Dynamic component={ elProps().component } { ...elProps().props }>
            { props.children }
        </Dynamic>
    )
};

At.tsx

アンカーのスクロール先のコンポーネントです。 Store の atId が自分の id と同じであれば、自分までスクロールを発動します。

import { createEffect } from "solid-js";
import { atId, setAtId } from "@/_global/stores/stores";
import type { AllAnchors } from '@/_global/lib/shared';
import type { DeepGuard } from "@/_global/types/types";

type Props = {
    id: AllAnchors;
};

export default function At(props: DeepGuard<Props>) {
    let el: HTMLSpanElement | undefined;

    createEffect(() => {
        if (!el || props.id !== atId()) return;

        el.scrollIntoView({ behavior: 'smooth' });
        setAtId('');
    });

    return (
        <span ref={ el } id={ `at_${props.id}` } class="at" />
  )
}

Init.tsx

ページアクセス時に読み込むコンポーネントです。URL にハッシュがあれば、Store に登録します。

先程の通り、SolidStart では window.location.hash の代わりに useLocation() を使います。また、Astro での MPA なので、SolidStart では onMount() の代わりに createEffect() を使います。

import { onMount } from "solid-js";
import { setAtId } from "@/_global/stores/stores";
import { observeScroll } from "@/_global/lib/observer";
import { anchors } from "@/_global/lib/shared";
import type { Anchors } from "@/_global/lib/shared";

export default function Init() {
    if (import.meta.env.SSR) return null;

    onMount(() => {
        const hash = window.location.hash.replace('#', '');
        if (!hash) return;

        setAtId(hash);
    });

    return null;
}

アンカー処理は、色々とやり方があると思いますが、一例ということでご理解いただければ幸甚です。