diff --git a/AGENTS.md b/AGENTS.md index 81fa763..0beb805 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,20 @@ # matplotlib GUI 引き継ぎ資料(AI エージェント向け) 要件の正は `docs/REQUIREMENTS.md`。このファイルと食い違う場合は REQUIREMENTS.md を優先する。 -この資料は Phase 3(出力品質: 保存 DPI・図サイズの単位・欧文フォント・プリセット・クリップボード・パネルの区分)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 +この資料は Phase 4(データ取り込み: ドラッグ&ドロップ・貼り付け・シート選択・読み込み設定(ヘッダ前の読み飛ばし・桁区切り・小数点・コメント記号)・日時の列と日時軸・複数ファイル)完了時点の実装を説明する。各 Phase の完了時に実装に合わせて更新すること(要件 A11)。 ## 1. ゴールと前提 -- CSV / TXT / XLSX のデータから、ブラウザ上の GUI だけで matplotlib の図を作る。 +- CSV / TXT / TSV / XLSX のデータから、ブラウザ上の GUI だけで matplotlib の図を作る。 - 利用形態は GitHub Pages 配信(公開 URL: https://mioupa.github.io/matplotlib_gui/)。`file://` で直接開く使い方は非対応。 - サーバー処理を持たない。計算はすべてブラウザ内の Python(PyScript / Pyodide)で行う。GitHub Pages は静的配信のみに使う。 - 読み込んだデータは外部に送信しない。外部への通信はライブラリとフォントの取得だけ(フォントは日本語の Noto Sans JP(TrueType 版)、選んだときだけ欧文の Arimo / Tinos。画面用の Web フォントは fontsource の Noto Sans JP)。 - 列の指定は列番号(`__idx__N`)で行う。同名の列があっても区別できる。 - 自動読込・自動描画を保つ(「読み込む」「描画する」ボタンは置かない)。 - 図は、設定から生成した Python スクリプトの実行だけで描く(描画経路は1本)。画面の図と、書き出した `.py` の結果は一致する。 -- データ確認タブは、描画から除外される先頭行(`skipRows`)をグレー(`.skipped-row`)で表示する。 +- データ確認タブは、描画から除外される先頭行(`skipRows`)をグレー(`.skipped-row`)で表示する。ヘッダより前に読み飛ばした行(`load.skipLines`)は、表の上に枠で囲んで別に表示する(`#dataPreamble`。グレー表示とは見た目を分ける)。 - 保存 DPI(既定 300)、図サイズの単位(inch / cm / mm)、欧文フォント、スタイルプリセット、カラーパレット、クリップボードへのコピー、数式(mathtext)の案内が加わった。いずれも設定 → 生成コード → 実行の1本の経路に載る(GUI だけの特別扱いは無い)。 +- Phase 4 でデータの取り込みが広がった。ファイルのドラッグ&ドロップ(ページ全体)と表の貼り付け(`pasted_data.tsv` として扱う)、`.tsv`、xlsx のシート選択、ヘッダより前に読み飛ばす行数、桁区切り・小数点・コメント記号、日時の列の自動認識と日時軸(X 軸の表示書式)、複数ファイル(最大10個。系列ごとにデータ元を選ぶ)。読込の引数は、生成コードの読込部にすべて明記する(区切り文字などの自動判定は足していない)。いずれも設定 → 生成コード → 実行の1本の経路に載る。 - CDN の URL はすべてバージョン固定にする。`tests/unit/test_cdn_pinning.py` が `index.html`・`js/`・`css/`・`py/` の全 URL を走査して強制する。取得しない説明リンクの `matplotlib.org` は、バージョンを含むパス(`/3.10.8/...`)だけ許可する。 ## 2. ファイル構成 @@ -24,36 +25,40 @@ | `pyscript.toml` | PyScript 設定(パッケージ、`packages_cache`、`[files]` による Python ファイルの配置) | | `css/style.css` | スタイル | | `js/main.js` | エントリ。UI を設定オブジェクトに結び付ける | -| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`、`SCHEMA_VERSION` = 2)、UD カラーの色(`PALETTE`) | +| `js/defaults.js` | 設定の既定値(`DEFAULT_SETTINGS`、`SCHEMA_VERSION` = 3)、UD カラーの色(`PALETTE`) | | `js/units.js` | 図サイズの単位(`UNITS`、`UNIT_NAMES`、`UNIT_STEP`)と換算 `convertLength(value, from, to)`(mm を介し、to の単位で丸める: in 3 桁 / cm 2 桁 / mm 1 桁)。純粋な ES module | | `js/palettes.js` | カラーパレット(`PALETTES`: `ud` / `tab10` / `gray`)、`getPalette`、`paletteColor(id, index)`。id は `settings.PALETTES` と一致させる。色は JS だけが持つ | | `js/presets.js` | スタイルプリセット(`PRESETS`: standard / paper1 / paper2 / slide)、`planPreset`(適用内容を計算するだけ)、`newSeriesDefaults`(直前のプリセットを新しい系列へ)。名前は設定に入れない | -| `js/code-state.js` | Pythonコードタブの状態(モード `sync` / `edit`、直近に生成したコードとその世代)。設定オブジェクトには入れない | -| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`applyPalette`、`setLastPreset` / `getLastPreset`、`subscribe`、`toJson`)。直近に適用したプリセットはセッション中だけここに持つ | -| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存・コピー(`makeClipboardImage`)・コードだけの更新(`script`)の制御、世代管理、日本語・欧文フォント取得の起動と待ち | +| `js/code-state.js` | Pythonコードタブの状態(モード `sync` / `edit`、直近に生成したコードとその世代、「最新でない」印 `isCodeStale` / `setCodeStale`)。設定オブジェクトには入れない | +| `js/state.js` | 設定オブジェクトの保持・更新・購読(`getSettings`、`setPath`、`updateSeries`、`addSeries`、`removeSeries`、`applyPalette`、`setFileSheet`、`setLastPreset` / `getLastPreset`、`subscribe`、`toJson`)。直近に適用したプリセットはセッション中だけここに持つ | +| `js/bridge.js` | Python(`window.mplgui`)の唯一の窓口。読込・描画・保存・コピー(`makeClipboardImage`)・コードだけの更新(`script`)の制御、世代管理、日本語・欧文フォント取得の起動と待ち。読み込んだファイル(データN)ごとの状態(`File`・バイト列・貼り付けか・`loading` / `ready` / `error`・結果)を持ち、ファイルの追加・置き換え・削除(`selectFiles` / `selectFile` / `removeFile`)、読込の予約(`scheduleLoad`。ファイルごと)、最後のファイルを外したときの初期状態への復帰、コードの「最新でない」印を扱う | | `js/startup-progress.js` | Python 起動中の段階表示。Resource Timing(`PerformanceObserver`、取得済みエントリを含む)から Pyodide 本体(`pyodide.asm.wasm`・`python_stdlib.zip`)→ パッケージ wheel(pandas / matplotlib)→ 初期化(`/py/main.py` の取得)を推定する | | `js/font-cache.js` | フォントの取得と Cache Storage への保存。種類(`japanese` / `arimo` / `tinos`)ごとの URL(`FONT_URLS`)、種類ごとに1セッション1回の取得、旧 OTF のキャッシュ削除 | | `js/ui/codeTab.js` | Pythonコードタブ(行番号、モード別のボタン、コピー、.py 保存、Ctrl/Cmd+Enter) | | `js/ui/codeOutput.js` | 「実行結果」欄(`#codeOutput`)。print の出力とトレースバックを表示する | +| `js/ui/loadSection.js` | データ読み込み区分のシート選択(`#sheetGroup` / `#sheetSelect`。xlsx のときだけ表示し、選んだシートは `load.files` に書く) | +| `js/ui/dropPaste.js` | ファイルのドロップ(ページ全体。`#dropOverlay` と、`#dropLoad` / `#dropReplace` / `#dropAdd` の区画)、表の貼り付け(`#pasteArea` とページ上の paste)、「貼り付けたデータを保存(.tsv)」(`#savePastedBtn`)。どれも `bridge.js` の `selectFiles` / `selectFile` に渡すだけ | +| `js/ui/fileList.js` | 2ファイル以上のときのファイル一覧(`#fileList`。各行 `file-`・シートの選択・削除)と、データ確認タブの表示元の切替(`#previewSource`)。`bridge.js` が作る表示用のデータを描くだけ | +| `js/ui/dateFormat.js` | 軸・目盛区分の X 軸の日時の書式(`#xDateFormat` のプリセットと「任意」の `#xDateFormatCustom`)。値は `axes.x.dateFormat` に書く。表示の可否(`#xDateFormatGroup`)は `series.js` が決める | | `js/ui/panelSections.js` | 設定パネルの区分(`details.panel-section`)の折りたたみ。状態は localStorage(キー `mplgui.panelSections`、`{区分の id: 開いているか}`)だけに持ち、設定オブジェクトには入れない。localStorage が使えなくても動く(すべて開く) | | `js/ui/styleSection.js` | 体裁区分: プリセットの「適用」、カラーパレット、図サイズの単位切替(数値を換算し、ラベルと step を単位に合わせる) | | `js/ui/saveSection.js` | 保存区分: DPI(72 / 150 / 300 / 600 のセレクトと「任意」の入力欄)。値は `save.dpi`(数値)に書く | | `js/ui/clipboard.js` | 「クリップボードにコピー」。`` を更新する | -| `js/ui/*.js`(上記以外) | 画面部品(`colorPicker`、`dataPreview`、`fileInfo`(選択中のファイル名と文字コードの表示)、`forms`、`notify`、`plotView`(画像と `data-summary`)、`progress`、`saveFormat`(形式に応じた透過の可否と、SVG の文字・PDF の説明の出し分け)、`series`、`tabs`) | -| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録(`render` / `save` / `copyImage` / `script` / `registerFont` など)、Excel 用ライブラリの遅延導入、作業フォルダ `api.SESSION.workdir` の設定) | -| `py/mplgui/settings.py` | 設定スキーマ(version 2)、既定値、検証、日本語エラー(凍結 dataclass へ変換)、`migrate_settings`(v1 → v2) | -| `py/mplgui/loader.py` | バイト列から DataFrame(文字コード判定、区切り文字、プレビュー)。読込結果に `SourceInfo`(ファイル名、種別、文字コード、区切り文字、ヘッダの有無、xlsx のシート名)を付ける。codegen が生成スクリプトの読込部に使う | -| `py/mplgui/dataprep.py` | DataFrame を見て決める処理。`plan_plot` が描画計画 `PlotPlan` を作る(Y 列の自動割り当て、X の数値変換の要否、描ける点の有無、A7 の警告、データ由来の日本語 `UserError`) | -| `py/mplgui/codegen.py` | 設定・`SourceInfo`・`PlotPlan` から matplotlib スクリプトを生成する(`generate_script`)。純粋な関数 | +| `js/ui/*.js`(上記以外) | 画面部品(`colorPicker`、`dataPreview`(先頭100行、前置き行の枠、日時の列の印)、`fileInfo`(選択中のファイル名と文字コードの表示)、`forms`、`notify`、`plotView`(画像と `data-summary`)、`progress`、`saveFormat`(形式に応じた透過の可否と、SVG の文字・PDF の説明の出し分け)、`series`(系列カード。複数ファイルのときはデータ元の選択と、データ元ごとの列の選択肢。X の日時の列の有無)、`tabs`) | +| `py/main.py` | PyScript のエントリ。JS と `mplgui` の橋渡しだけを書く(`window.mplgui` の登録(`loadFile` / `removeSource` / `clearSources` / `render` / `save` / `copyImage` / `script` / `registerFont` など)、Excel 用ライブラリの遅延導入、作業フォルダ `api.SESSION.workdir` の設定) | +| `py/mplgui/settings.py` | 設定スキーマ(version 3)、既定値、検証、日本語エラー(凍結 dataclass へ変換。`FileEntry`、読込設定の検証、`axes.x.dateFormat`)、`migrate_settings`(v1 → v2 → v3) | +| `py/mplgui/loader.py` | バイト列から DataFrame(文字コード判定、区切り文字、読込設定(`skip_lines` / `thousands` / `decimal` / `comment`)、xlsx のシート、日時の列の認識、プレビューと前置き行)。読込結果に `SourceInfo`(ファイル名、種別(csv / txt / tsv / xlsx)、文字コード、区切り文字、ヘッダの有無、読込設定、シート名とシート一覧、日時に変換した列 `datetime_columns`、貼り付けたデータか `pasted`)を付ける。`SourceInfo.read_kwargs()` が `pd.read_csv` / `pd.read_excel` の引数の唯一の定義で、loader と codegen が共有する。`split_lines`(pandas と同じく `\r` / `\n` / `\r\n` だけで行を分ける)、`detect_datetime_columns` / `convert_datetime` / `apply_datetime_columns`、`open_excel`(`pd.ExcelFile`)も持つ | +| `py/mplgui/dataprep.py` | DataFrame(または `{ファイル id: DataFrame}`)を見て決める処理。`plan_plot` が描画計画 `PlotPlan` を作る(データ元ごとの `skipRows` と Y 列の自動割り当て、X の数値変換・日時・文字列の別、描ける点の有無、A7 の警告(桁区切り・小数点のヒント付き)、日時の X の検査、データ由来の日本語 `UserError`)。描画に使うデータ元は `PlotPlan.sources`(`PlanSource`: id・番号・変数名・ファイル名)と `SeriesPlan.data_id` / `data_var` に持つ | +| `py/mplgui/codegen.py` | 設定・`SourceInfo`(または `{ファイル id: SourceInfo}`)・`PlotPlan` から matplotlib スクリプトを生成する(`generate_script`)。純粋な関数 | | `py/mplgui/formats.py` | 保存形式の表(`FORMATS`)、保存解像度の既定値(`DEFAULT_SAVE_DPI` = 300。実際の値は `save.dpi`)、`build_filename`、`savefig_kwargs`、形式ごとの rcParams(`savefig_rc`)、PNG / JPG の画素数の上限(`MAX_RASTER_PIXELS`、`MAX_RASTER_SIDE`、`raster_pixels`、`check_raster_size`)。runner と codegen が共有する | | `py/mplgui/runner.py` | スクリプトの実行(`run_script` / `run_to_image`)、Figure → 画像(PNG/JPG/SVG/PDF。`savefig_rc` を `rc_context` で適用、画素数の検査)、図の要約(`figure_summary`)、実行時エラーの整形、数式の誤りの判定(`is_mathtext_error`、`MathtextError`) | | `py/mplgui/fonts.py` | フォントの登録と rcParams。日本語の一覧 `SANS_SERIF_PRIORITY`(先頭は Noto Sans JP)と、欧文フォントの表 `LATIN_FONTS`(Arimo / Tinos の表示名・候補名・mathtext フォントセット)が唯一の定義で、生成スクリプトもこれを使う。`register_font_bytes(data, kind=)` は種類(`japanese` / `arimo` / `tinos`)ごとに1回だけ登録する | | `py/mplgui/errors.py` | `UserError`(利用者が直せるエラー) | -| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数(`render_json` / `save_json` / `copy_image_json` / `script_json` など)。読込済みデータ(`SESSION`)と、アップロードしたファイルの作業フォルダへの書き出しを持つ | +| `py/mplgui/api.py` | JS から呼ばれる JSON 入出力の関数(`render_json` / `save_json` / `copy_image_json` / `script_json` など)。読込済みデータ(`SESSION`。ファイル id ごとの `LoadedSource`(`df`・`SourceInfo`・書き出したファイル)と、xlsx の `ExcelFile` のキャッシュ(同じバイト列の間だけ)。`remove_source_json` / `clear_sources_json` も持つ)と、アップロードしたファイルの作業フォルダへの書き出しを持つ | | `py/mplgui/runtime.py` | 警告フィルタ(起動時) | | `tests/unit/` | 単体テスト(CPython、Agg)。`test_js_modules.py` は node で純粋な JS モジュール(units / palettes / presets / state)を検査する | | `tests/e2e/` | E2E テスト(pytest-playwright、Chromium)。`run_script_summary.py` は、生成スクリプトを CPython で実行して図の要約を出す補助(一致確認用) | -| `tests/fixtures/` | 合成テストデータ(`make_fixtures.py` が生成) | +| `tests/fixtures/` | 合成テストデータ(`make_fixtures.py` が生成。説明は `README.md`。Phase 4 で `preamble` / `european` / `thousands` / `comments` / `datetime` / `datetime_iso_tz` / `dates_ja` などを追加) | | `tests/perf/bench.py` | 起動・描画の計測。結果は `docs/PERFORMANCE.md` | | `.github/workflows/tests.yml` | CI | | `_config.yml` | GitHub Pages(Jekyll)の include / exclude | @@ -62,18 +67,25 @@ ## 3. 設定オブジェクト -- スキーマのバージョンは 2(`version`)。JSON のキーは camelCase。 +- スキーマのバージョンは 3(`version`)。JSON のキーは camelCase。 - 唯一の正は JS 側(`js/state.js`)の設定オブジェクト。Python は DOM を読まず、渡された JSON を `settings.parse_settings` / `parse_load_settings` で凍結 dataclass に変換して使う。 - JS の既定値(`js/defaults.js`)と Python の既定値(`settings.default_settings()`)は一致させる。`tests/unit/test_defaults_parity.py` が比較する(node が無いと skip)。 -- 主な構造: `load`(`delimiter`、`hasHeader`)、`plot`(`type`、`skipRows`、`xColumn`、`title`、`fontSize`、`figure`、`latinFont`、`palette`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`)、`series[]`、`save`(`filename`、`format`、`transparent`、`dpi`、`svgText`)。 +- 主な構造: `load`(`delimiter`、`hasHeader`、`skipLines`、`thousands`、`decimal`、`comment`、`parseDates`、`files`)、`plot`(`type`、`skipRows`、`xColumn`、`title`、`fontSize`、`figure`、`latinFont`、`palette`、`legend`、`grid`、`margins`)、`axes`(`x` / `y` / `y2`: `label`、`scale`、`min`、`max`。`x` だけ `dateFormat`)、`series[]`、`save`(`filename`、`format`、`transparent`、`dpi`、`svgText`)。 - Phase 3 で足した項目と既定値: - `plot.figure`: `{width: 8, height: 6, unit: "in"}`。`unit` は `in` / `cm` / `mm`。`width` / `height` は選んだ単位の値で持つ(インチに直して持たない)。Python が検証するときだけ `FigureSettings.width_in` で「値 / 1インチあたりの長さ(2.54 / 25.4)」と割ってインチにする。上限は 50 inch を選んだ単位に直した値(in 50 / cm 127 / mm 1270)で、超えると単位つきの日本語エラー。 - `plot.latinFont`: `default` / `arimo` / `tinos`(既定 `default`)。`plot.palette`: `ud` / `tab10` / `gray`(既定 `ud`。Python は選択肢の検証だけで、色は JS の `palettes.js` が持つ)。 - `save.dpi`: 整数(既定 300、50〜1200、整数に見える数値・数値文字列・空欄=既定値を受け付ける。それ以外は「保存 DPI」の日本語エラー)。`save.svgText`: `path`(文字を図形にする。既定)/ `text`(文字のまま)。 -- `settings.migrate_settings(raw)`: `parse_settings` / `parse_load_settings` / `parse_save_settings` が最初に通す(純粋。引数は変更しない)。version が無ければ現在のバージョン扱い。v1 → v2 は `save.dpi` が無ければ 120 にする(v1 の保存は常に 120 dpi だったので同じ出力になる)。図の単位・欧文フォント・パレット・SVG の文字は既定値で補われる。1 と 2 以外は「設定のバージョン…に対応していません」の `UserError`。 +- `settings.migrate_settings(raw)`: `parse_settings` / `parse_load_settings` / `parse_save_settings` が最初に通す(純粋。引数は変更しない)。version が無ければ現在のバージョン扱い。v1 → v2 は `save.dpi` が無ければ 120 にする(v1 の保存は常に 120 dpi だったので同じ出力になる)。図の単位・欧文フォント・パレット・SVG の文字は既定値で補われる。v2 → v3 は `load.parseDates` が無ければ **false** にする(v2 までの出力を保つ。Phase 3 の dpi 120 と同じ考え方。v3 で新しく作る設定の既定値は true)。他の新項目は既定値で補われる。1・2・3 以外は「設定のバージョン…に対応していません」の `UserError`。 +- Phase 4 で足した項目と既定値(スキーマ version 3): + - `load.skipLines`(0): ヘッダより前に読み飛ばす行数。整数(上限 1,000,000)。`field` は「ヘッダより前に読み飛ばす行数」。`plot.skipRows`(描画から除外する先頭行数。ヘッダの後のデータ行を数える)とは別の項目。 + - `load.thousands`(`""`。`""` / `,` / `.` / 空白 / `'`)、`load.decimal`(`.`。`.` / `,`)、`load.comment`(`""` または1文字)。thousands と decimal が同じ記号、comment が `"`・空白・thousands・decimal と同じ、はいずれも日本語の `UserError`(`field` は「桁区切り」「コメント記号」)。pandas の `thousands` / `decimal` / `comment` にそのまま渡す。 + - `load.parseDates`(true): 日時の列を自動で認識する(§7)。 + - `load.files`(`[]`): `[{id, name, sheet}]`。`id` は `d` + 数字(`d1`, `d2`, ...。削除しても再利用しない)、`sheet` が `""` なら先頭シート。最大 10 個(超えると「読み込めるファイルは10個までです。」)。画面の「データN」は、この配列の位置(N = 位置 + 1)。名前・シートの変更は、そのファイルだけを読み直す。 + - `series[].source`(`""`): データ元のファイル id。`""` は `load.files` の先頭。`load.files` に無い id は「系列Nのデータ元が見つかりません。…」(`field` は「系列Nのデータ元」)。 + - `axes.x.dateFormat`(`""`): X 軸の日時の表示書式(strftime)。`""` は自動。空でなければ `%` を含み、`strftime` が通ること(64 文字以下)。誤りは「X軸の日時の書式」の日本語エラー。`axes.y` / `axes.y2` には無い。 - パレットの色、プリセットの値、単位の換算は JS だけが持つ(`js/palettes.js`、`js/presets.js`、`js/units.js`)。プリセット名は設定に入れない。「適用」は図サイズ・単位・フォントサイズ・各系列の線幅と点サイズを個別の設定に書き込むだけで、直近のプリセットは `state.js` がセッション中だけ覚える(新しい系列に、線幅と、scatter のときの点サイズを引き継ぐ)。点サイズは、プリセットが自動(標準)なら全系列を自動に戻し、数値なら scatter か点サイズを入力済みの系列だけ書き換える。パレットの切替(`state.applyPalette`)は、全系列を系列の順に新しいパレットの色で塗り直してから `plot.palette` を書く。新しい系列の既定色は現在のパレットの未使用色。 -- 系列: `id`、`x`、`y`、`color`、`lineWidth`、`lineStyle`、`markerSize`、`label`、`secondaryAxis`。`markerSize: null` は「自動」(line は 0、scatter は 24)。ユーザーが数値を入れたら自動切替の対象外。 -- Pythonコードタブの状態(モード `sync` / `edit`、生成したコード)は設定オブジェクトに入れず、`js/code-state.js` に持つ。スキーマは version 1 のまま。編集モードのコード本文は、`render` / `save` の別引数 `code` で渡す。「使用する」(`useCustomCode`)は廃止した。 +- 系列: `id`、`x`、`y`、`color`、`lineWidth`、`lineStyle`、`markerSize`、`label`、`secondaryAxis`、`source`。`markerSize: null` は「自動」(line は 0、scatter は 24)。ユーザーが数値を入れたら自動切替の対象外。 +- Pythonコードタブの状態(モード `sync` / `edit`、生成したコード)は設定オブジェクトに入れず、`js/code-state.js` に持つ。編集モードのコード本文は、`render` / `save` の別引数 `code` で渡す。「使用する」(`useCustomCode`)は廃止した。 ## 4. Python ⇄ JS の API(`window.mplgui`) @@ -82,12 +94,14 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 | 関数 | 引数 | 戻り値(`ok: true` のとき) | |---|---|---| | `ensureExcel()` | なし(非同期) | `{ok}`。初回の .xlsx 読込前に openpyxl を導入する | -| `loadFile(name, bytes, loadJson)` | ファイル名、内容、`{version, load}` | `{encoding, columns, preview, warnings}`(.xlsx は `encoding: null`) | +| `loadFile(name, bytes, loadJson, sourceId, pasted)` | ファイル名、内容、`{version, load}`、データ元の id(省略時 `d1`)、貼り付けたデータか(省略時 false) | `{encoding, columns, preview, sheets, sheet, warnings}`。.xlsx は `encoding: null`、`sheets` はシート名の配列(xlsx 以外は `[]`)、`sheet` は読んだシート名(xlsx 以外は `null`)。`columns` の各要素は `{value, label, kind}`(`kind` は `datetime` / `number` / `text`)。`preview` は `{columns, columnKinds, rows, …, preamble: {lines, total}}`(`preamble` は読み飛ばした行の先頭最大20行と総数。`skipLines` が 0 のときは空) | | `render(settingsJson, code)` | 設定 JSON、`code`(null または文字列) | `code` が null: `{image, code, output, summary, seriesCount, skipRows, warnings}`。文字列: `{image, output, summary}` | | `save(settingsJson, code)` | 同上 | `{filename, mime, dataUri, output}` | | `copyImage(settingsJson, code)` | 同上 | `{mime, dataUri, width, height, output}`(保存形式にかかわらず PNG) | | `script(settingsJson)` | 設定 JSON | `{code}`(設定から生成した完全なスクリプトだけ。実行しない。読込済みデータが必要(無いと「先にファイルを読み込んでください。」)) | | `scriptFilename(saveFilename)` | 保存ファイル名の入力値 | 「.py で保存」のファイル名(文字列)。保存ファイル名と同じ規則(`formats.build_filename`)で拡張子を `.py` にする | +| `removeSource(id)` | ファイル id(`d1` など) | `{ok}`(そのデータ元と作業フォルダのファイルを取り除く。無い id でも `ok`) | +| `clearSources()` | なし | `{ok}`(すべてのデータ元と作業フォルダのファイルを取り除く。「置き換えて読み込む」の前に JS が呼ぶ) | | `registerFont(bytes, kind)` | フォントのバイト列、種類(`japanese`(省略時)/ `arimo` / `tinos`。他は「未対応のフォントの種類」の `UserError`) | `{registered, kind}`(種類ごとに登録は1回だけ。2回目以降は `registered: false`) | | `fontStatus()` | なし | `{registered, kinds}`(`registered` は日本語フォントの登録済みか、`kinds` は登録済みの種類の一覧) | @@ -95,7 +109,9 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - `code` が null(GUI 同期): 設定から生成したスクリプトの「自動描画用」(§4.1)を、読込済みの DataFrame(`df`)を渡して実行する。`image` は PNG の data URI、`code` は表示用の完全なスクリプト、`output` は実行中の print / stderr、`summary` は `runner.figure_summary` の軸ごとの要約。読込済みデータが必要。`save` は保存設定の形式・背景透過・`save.dpi`・`save.svgText` で書き出す(`render` のプレビューは dpi 100、`PREVIEW_DPI`)。形式ごとの rcParams(`formats.savefig_rc`: PDF は `pdf.fonttype=42`、SVG は `svg.fonttype` = `path` / `none`)は `matplotlib.rc_context` で `savefig` の間だけ適用する(生成コードの `plt.rcParams[...]` と同じ値)。`copyImage` は保存形式にかかわらず PNG を `save.dpi` で作り、背景透過は保存設定に従う(クリップボードにコピーする画像。C5)。`script` は、保存設定(`save.*`)の変更で表示中のコードだけを作り直すのに使う(§5)。 - `code` が文字列(編集モード。`save` / `copyImage` も同様): そのスクリプトを丸ごと、アップロードしたファイルのある作業フォルダ(`SESSION.workdir`)で実行する。`render` は設定を読まない(設定が不正でも、読込済みデータが無くても動く)。`save` は保存設定(`save.*`)だけを `settings.parse_save_settings` で検証して使う(描画設定が不正でも、編集したコードの図は保存できる)。GUI の設定をコードの結果に上書き適用することはしない(A8)。 -- `loadFile` は、読み込んだファイルを元のファイル名(フォルダ部分は除く)で作業フォルダに書き出す(前のファイルは消す。失敗したら読込済みデータと書き出したファイルを破棄する)。こうして、表示中のスクリプトの `pd.read_csv("ファイル名", ...)` がそのまま動く(P5)。 +- `loadFile` は、読み込んだファイルを元のファイル名(フォルダ部分は除く)で作業フォルダに書き出す(同じ id の前のファイルは消す。失敗したらその id の読込済みデータと書き出したファイルを破棄する)。貼り付けたデータは `pasted_data.tsv`(UTF-8、タブ区切り)という名前のファイルとして同じ経路で読む。こうして、表示中のスクリプトの `pd.read_csv("ファイル名", ...)` がそのまま動く(P5)。 +- 読込の規則(`loader.load_file`): 拡張子は `.csv` / `.txt` / `.tsv` / `.xlsx`(それ以外は「対応していない拡張子です。xlsx/csv/txt/tsvを選択してください。」)。区切り文字の既定は拡張子で決まる(csv はカンマ、txt は空白、tsv はタブ。自動判定はしない)。pandas に渡す引数は `SourceInfo.read_kwargs()`(csv: `sep`・`header`・`skiprows`・`thousands`・`decimal`・`comment`・`engine="python"`。xlsx: `sheet_name`・`header`・`skiprows`・`thousands`・`decimal`・`comment`)。xlsx のシートは `loadJson` の `load.files` のうち `sourceId` の項目から決め(無ければ先頭)、無い名前は「シート「X」がファイルにありません。…」(`field` は「シート」)。`pd.ExcelFile` は同じバイト列の間だけ id ごとにキャッシュする(シートの切替を速くする)。 +- 読込のエラーと警告(利用者に案内する): 区切りが空白で `thousands` が空白、`comment` が区切り文字と同じ、は `UserError`。列数が揃わない `ParserError` と、`skipLines` が大きすぎてデータが無いときは、「ヘッダより前に読み飛ばす行数」を案内する文を添える。小数点と区切り文字が同じ記号(1文字の区切りで `decimal == sep`)はエラーにせず警告にする(引用符で囲まれた値は読めるため)。結果が1列で先頭のデータ行に `;` かタブがあれば、区切り文字の指定漏れの警告(`DELIMITER_HINT`)を返す。前置き行は、csv / txt / tsv ではデコード後のテキストを `split_lines`、xlsx では先頭 `skipLines` 行をタブでつないで作る。 - `warnings` は `{series, message}` の配列(読込時は文字列配列の場合もある。JS の `warningTexts` が吸収する)。 - 失敗: `{ok: false, error: {message, field?, detail?, traceback?, line?}, output?}`。 - 利用者が直せる原因は `UserError`。`message` は日本語で、どの項目が悪いかを含める(`field`)。 @@ -106,22 +122,32 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - 画素数の検査(`formats.check_raster_size`): PNG / JPG は、書き出す前に `int(幅inch × dpi)` × `int(高さinch × dpi)`(Agg と同じ切り捨て。`raster_pixels`)が 1 億画素(`MAX_RASTER_PIXELS`)を超える、または1辺が 65536(`MAX_RASTER_SIDE`)以上なら、`UserError`(「保存する画像が大きすぎます(幅 N × 高さ M ピクセル)。保存 DPI か図のサイズを小さくしてください。」、`field` は「保存 DPI」)にする。`runner.figure_to_bytes` で行うので、`render`(dpi 100)・`save`・`copyImage`・編集モードのどれにも効く。SVG / PDF には使わない。 - 数式(mathtext)の書き間違い(C6): `runner.is_mathtext_error` が、matplotlib の `_mathtext.py` から出た `ValueError` または pyparsing の `ParseBaseException`(`__cause__` / `__context__` の連鎖を含む)を判定する。GUI 同期では、実行中(`tight_layout` など)でも、画像にするとき(`MathtextError`)でも、`api.MATHTEXT_ERROR`(`field` は「数式」。例 `m$^2$`、`H$_2$O`、`$\alpha$`、`\$` を案内する日本語)にする。編集モードでは、`runner.MATHTEXT_HINT` が日本語の説明(`japanese_hint`)として「N行目、ValueError: …」に付く(実行中に見つかったとき。画像にするときに見つかった場合は `field` が「数式」)。内部エラーにはしない。 +### 複数ファイル(Phase 4 D6) + +- `loadFile` は id ごとに読み込み、その id のデータ元だけを置き換える(失敗したら、その id だけを破棄。ほかは残る)。`api.Session.sources` は `{id: LoadedSource(df, info, written)}`、Excel の `ExcelFile` も id ごと。 +- データ元の解決: `series.source` が空なら `load.files` の先頭、`load.files` が空なら読み込んだ先頭。読み込めていないデータ元を使う系列は「データN(name)を読み込めていません…」(`field` は「系列Nのデータ元」)。`dataprep.plan_plot(data, settings)` は DataFrame または `{id: DataFrame}` を受け、`skipRows` と Y の自動割り当てをデータ元ごとに行う。棒グラフは全系列が同じデータ元(違えば「データ元」のエラー)。 +- 生成コード: `load.files` が 1 件以下なら従来どおり(`DATA_FILE` / `df`)。2 件以上なら、系列が使うファイルだけを `DATA_FILE_N` / `dfN`(N は一覧の位置 = 画面の「データN」)で読む。自動描画では `{"df1": ..., "df2": ...}` を注入する。 +- JS(`bridge.js`): ファイルごとに `{File, bytes, pasted, state(loading|ready|error), result, error}` を持つ。`#fileInput`(置き換え)、`#addFileBtn` / `#addFileInput`(追加)、ドロップの `#dropReplace` / `#dropAdd`、一覧 `#fileList`(行 `file-`、`file--sheet`、`file--remove`)、データ確認の `#previewSource`、系列の `series--source`。同じ名前のファイルは同じ id で置き換え、上限は 10 個。`data-data-state` は読込中が1つでもあれば `loading`、失敗が1つでもあれば `error`(ほかのファイルが読めていれば描画は行い、失敗は警告に出す)。`data-load-count` は、読み込めたデータ元がある読込の完了ごとに増える。 + ## 4.1 コード生成(Phase 2) 描画経路は1本: 設定 → `dataprep.plan_plot`(データを見て決める処理と検査)→ `codegen.generate_script`(スクリプトの文字列)→ `runner`(実行して Figure を得て画像にする)。GUI の図と、書き出す `.py` は同じコードから作る。 -- `dataprep.plan_plot(df, settings)`: `skipRows` の適用、Y 列の自動割り当て、X の数値変換の要否、描ける点があるか、数値に変換できず除外した値の警告(A7)を決める。データ由来の問題は日本語の `UserError`(列が範囲外、除外行数が多すぎる、描ける数値データが無い、など)にする。結果は凍結 dataclass の `PlotPlan` / `SeriesPlan`。 +- `dataprep.plan_plot(data, settings)`(`data` は DataFrame または `{ファイル id: DataFrame}`): データ元の解決(`series.source` が空なら `load.files` の先頭)、データ元ごとの `skipRows` の適用と Y 列の自動割り当て、X の数値・日時・文字列の別、描ける点があるか、日時の X の検査(日時と日時でない X の混在、X が日時のときの対数軸・範囲指定、Y が日時、はいずれも日本語の `UserError`)、数値に変換できず除外した値の警告(A7)を決める。データ由来の問題は日本語の `UserError`(列が範囲外、除外行数が多すぎる、描ける数値データが無い、など)にする。結果は凍結 dataclass の `PlotPlan` / `SeriesPlan`。 - `codegen.generate_script(settings, source, plan)` は `GeneratedScript`(`text`、`load_lines`、`output_lines`、`steps`)を返す。純粋な関数で、データには触れない。 -- スクリプトの構成: ヘッダ(使い方、`import`、日本語フォントの rcParams、欧文フォントの設定(下記)、`FONT_SIZE`)→ `1. データの読み込み`(`DATA_FILE`、`pd.read_csv` / `pd.read_excel`。文字コード、区切り文字、ヘッダ、シート名を明記)→ `2. 描画に使う行`(`skipRows`)→ `3. 図と軸`(`fig, ax`。単位ごとの書き方は下記)→ `4. 系列` → `5. 軸(ラベル・スケール・範囲・目盛)` → `6. グリッドと凡例` → `7. 余白` → `8. 保存と表示`(形式ごとの rcParams、`fig.savefig(...)`、`plt.show()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。 +- スクリプトの構成: ヘッダ(使い方、`import`、日本語フォントの rcParams、欧文フォントの設定(下記)、`FONT_SIZE`)→ `1. データの読み込み`(`DATA_FILE`、`pd.read_csv` / `pd.read_excel`。読込の引数はすべて1行に1つ、コメント付きで明記する(下記))→ `2. 描画に使う行`(`skipRows`)→ `3. 図と軸`(`fig, ax`。単位ごとの書き方は下記)→ `4. 系列` → `5. 軸(ラベル・スケール・範囲・目盛)` → `6. グリッドと凡例` → `7. 余白` → `8. 保存と表示`(形式ごとの rcParams、`fig.savefig(...)`、`plt.show()`)。各セクションの見出しは `# ==== 番号. タイトル ====`。 +- `1. データの読み込み`(Phase 4): 引数は `SourceInfo.read_kwargs()` の順に、既定値も含めてすべて書く(csv: `encoding`(自動判定)、`sep`、`header`、`skiprows`、`thousands`、`decimal`、`comment`、`engine="python"`。xlsx: `sheet_name`、`header`、`skiprows`、`thousands`、`decimal`、`comment`。xlsx では「ファイル内のシート」の一覧もコメントに書く)。ヘッダなしのときは続けて `df.columns = [f"column_{i}" ...]`。日時の列があれば、`# 日時の列を日時に変換する…` に続けて列ごとに `df.isetitem(N, pd.to_datetime(df.iloc[:, N], format="…", errors="coerce")) # 列名 [N]`(前後の空白があれば `.str.strip()`、タイムゾーンの混在は `utc=True`、タイムゾーン付きは `.dt.tz_localize(None)`)。loader も同じ式(`convert_datetime`)を同じ順で適用するので、画面のデータと生成コードの読込結果は一致する。貼り付けたデータ(`SourceInfo.pasted`)のときは、読込部の先頭と「使い方」に「貼り付けたデータを保存して `pasted_data.tsv` として置く」案内を書く(データはスクリプトに埋め込まない)。 +- 複数ファイル: `load.files` が1件以下なら `DATA_FILE` / `df`。2件以上なら、系列が使うファイルだけを `DATA_FILE_N` / `dfN`(N = `load.files` の位置 + 1 = 画面の「データN」。使わないファイルがあっても番号は詰めない)で、「データN: ファイル名」の見出しコメント付きで読む。`2. 描画に使う行`(`skipRows`)も `dfN` ごと。系列のコメントと変数は `p.data_var` を使う。 +- 軸(Phase 4): X が日時のとき(line / scatter)、`axes.x.dateFormat` が空なら `mdates.AutoDateLocator()` と `mdates.ConciseDateFormatter(date_locator)`、指定があれば `mdates.DateFormatter()`。`import matplotlib.dates as mdates` は日時軸のときだけ書く。棒グラフで X が日時のときは `.dt.strftime(<書式>)` でカテゴリの文字列にして並べる(書式は `dateFormat`、空なら全部 0 時 `%Y-%m-%d` / 秒が全部 0 `%Y-%m-%d %H:%M` / それ以外 `%Y-%m-%d %H:%M:%S`)。X が日時でない文字列の列のときは、Phase 2 の `ax.set_xscale("linear")` を入れず、目盛に X の値(カテゴリ)を出す。描く系列の X のカテゴリ(重複を除く)が 25 個を超えるときだけ `ticker.MaxNLocator(nbins=25, integer=True)` で間引く(`import matplotlib.ticker as ticker` もそのときだけ)。 - 欧文フォント(`plot.latinFont` が `arimo` / `tinos` のときだけ。`default` ではヘッダは Phase 2 と同じ): ヘッダに `from matplotlib import font_manager` を足し、`installed = {font.name for font in font_manager.fontManager.ttflist}`、`latin = [name for name in [候補…] if name in installed][:1]`、`plt.rcParams["font.family"] = latin + ["sans-serif"]` を書く。候補は `fonts.LATIN_FONTS` の並び(Arimo: `Arimo`、`Arial`、`Liberation Sans`。Tinos: `Tinos`、`Times New Roman`、`Liberation Serif`)。入っているフォントだけを選ぶのは、無い名前を `font.family` に並べると、matplotlib が描画のたびに `findfont` の警告を出すため(ローカルで実行しても警告を出さず、どれも無ければ日本語フォントで書く)。日本語の文字は、リストの後ろの `sans-serif`(`font.sans-serif` の日本語フォント)に、matplotlib の文字単位のフォールバックで回る。Tinos(`mathtext_fontset` = `stix`)のときは `plt.rcParams["mathtext.fontset"] = "stix"` も書く(数式も Times 系の字形にする。STIX は matplotlib 同梱)。 - `3. 図と軸`: `figsize` はインチ。`unit` が `in` なら `figsize=(幅, 高さ)`。`cm` / `mm` なら `CM_PER_INCH = 2.54`(`MM_PER_INCH = 25.4`)を定義し、`figsize=(幅 / CM_PER_INCH, 高さ / CM_PER_INCH)` と割る(コメントに単位と値を書く)。`1 / 2.54` を掛けると、20.32 cm が 7.999… inch になり、Agg の画素数(切り捨て)が 1 画素欠けるため、割り算にする(Python 側の `FigureSettings.width_in` も同じ割り算)。 - `8. 保存と表示`: `formats.savefig_rc(format, svg_text)` が返す rcParams を、`plt.rcParams["pdf.fonttype"] = 42` / `plt.rcParams["svg.fonttype"] = "path"|"none"` としてコメント付きで `fig.savefig(...)` の前に書く(runner が `rc_context` で適用する値と同じ)。`savefig` の `dpi` は PNG / JPG のときだけ入れ、値は `save.dpi`(コメントに「N dpi」)。PDF のときは、手元の日本語フォントが OpenType(CFF)形式(macOS のヒラギノなど)だと文字が正しく表示されないことがあるという注意を、コメント2行で添える(原因は §6)。 - 変数名は、`df`(データ)、`fig` / `ax` / `ax2`(第2Y軸)、系列ごとの `x` / `y` / `ok`(欠けた行を除くマスク)、棒グラフ(複数系列)の `bar_data` / `positions` / `x_labels` / `width`。列は常に `df.iloc[:, N]`(列番号)で参照し、GUI で選んだ列名と番号はコメントに書く(同名の列を区別できる)。列名はコードに入れない。 - 自動描画用の変種(`GeneratedScript.auto_render_text`): 「データの読み込み」と「保存と表示」の行を空行にしたもの。行番号は表示中のスクリプトと同じなので、トレースバックの行番号がそのまま使える。GUI の自動描画は、これに読込済みの `df` を渡して実行する(ファイルを読み直さない)。 - 手順(`Step`): 失敗しうる行の範囲と日本語メッセージ(`field` 付きのものもある)の対応表。系列の描画(`GENERIC_ERROR_MESSAGE`)、軸のスケール・範囲(X / Y / 第2Y)、`tight_layout`、`subplots_adjust`(余白)を登録している。 -- **安全性(必須)**: 列名・タイトル・ラベル・色・ファイル名・文字コードなどは信頼できない入力で、実行されるコードに入る。値は必ず `literal()`(str / int / 有限の float / bool / None だけを `repr` で出す。他の型は例外)を通して書き出し、コメントに入れる文字列は必ず `comment_text()`(改行・制御文字を空白にする)を通す。f 文字列などで、これ以外の方法で信頼できない文字列をコードに埋め込まないこと。`tests/unit/test_codegen.py` の注入テストが検査する。 +- **安全性(必須)**: 列名・タイトル・ラベル・色・ファイル名・文字コード・シート名・コメント記号・日時の書式・貼り付けたデータのファイル名などは信頼できない入力で、実行されるコードに入る。値は必ず `literal()`(str / int / 有限の float / bool / None だけを `repr` で出す。他の型は例外)を通して書き出し、コメントに入れる文字列は必ず `comment_text()`(改行・制御文字を空白にする)を通す。f 文字列などで、これ以外の方法で信頼できない文字列をコードに埋め込まないこと。`tests/unit/test_codegen.py`(と `test_load_options.py` / `test_datetime.py` / `test_multi_source.py` / `test_paste_tsv.py`)の注入テストが、シート名・ファイル名・コメント記号・日時の書式・列名で検査する。 - GUI の設定を、ユーザーが編集したコードの結果に上書き適用しない(A8)。編集モードのコードは、設定を使わずそのまま実行する。 -- 機能追加(Phase 3〜5)の手順: 描画の種類は `SERIES_EMITTERS`(プロット種別 → 系列の出力関数)に追加する。出力関数は `_Builder` に行を足し、失敗しうる行は `with b.step(メッセージ, field)` で包む。軸・グリッド・余白などは対応する `_emit_*` 関数に足す。データを見ないと決められないこと(列の割り当て、変換の要否、警告、データ由来のエラー)は `dataprep` の `PlotPlan` / `SeriesPlan` に項目を足して渡す。設定項目を足すときは JS の `defaults.js` と `settings.py` の両方に足す。生成するコードは pandas / matplotlib / numpy だけを使う(ローカルの Python でそのまま動かすため)。新しい行を足したら、単体テストで「全体を実行した図」と「自動描画用の図」が一致することも確かめる。保存形式ごとの rcParams は `formats.savefig_rc` に足す(runner と codegen が同じ表を使う)。欧文フォントを足すときは `fonts.LATIN_FONTS`・`FONT_FILENAMES`、JS の `font-cache.js` の `FONT_URLS`・`bridge.js` の `LATIN_NAMES`、`settings.LATIN_FONTS`、`index.html` の選択肢を揃える。 +- 機能追加(Phase 5〜)の手順: 描画の種類は `SERIES_EMITTERS`(プロット種別 → 系列の出力関数)に追加する。出力関数は `_Builder` に行を足し、失敗しうる行は `with b.step(メッセージ, field)` で包む。軸・グリッド・余白などは対応する `_emit_*` 関数に足す。データを見ないと決められないこと(列の割り当て、変換の要否、警告、データ由来のエラー)は `dataprep` の `PlotPlan` / `SeriesPlan` に項目を足して渡す(データ元ごとに違うことは `SeriesPlan.data_id` / `data_var` と `PlotPlan.sources` に持つ。読込の仕方に関わる引数は `SourceInfo.read_kwargs()` に足し、codegen の `_READ_COMMENTS` にコメントを足す)。設定項目を足すときは JS の `defaults.js` と `settings.py` の両方に足す。生成するコードは pandas / matplotlib / numpy だけを使う(ローカルの Python でそのまま動かすため)。新しい行を足したら、単体テストで「全体を実行した図」と「自動描画用の図」が一致することも確かめる。保存形式ごとの rcParams は `formats.savefig_rc` に足す(runner と codegen が同じ表を使う)。欧文フォントを足すときは `fonts.LATIN_FONTS`・`FONT_FILENAMES`、JS の `font-cache.js` の `FONT_URLS`・`bridge.js` の `LATIN_NAMES`、`settings.LATIN_FONTS`、`index.html` の選択肢を揃える。 - 生成スクリプトの保存ファイル名(`savefig` の引数)は `formats.build_filename` で決める。保存形式・解像度・形式ごとの rcParams は `formats.savefig_kwargs`(解像度は `save.dpi`)/ `formats.savefig_rc` で、GUI の「保存」と同じにする。 ### runner(スクリプトの実行) @@ -135,21 +161,25 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 ## 5. 描画パイプライン -- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更で 0 ms、区切り文字の変更で 450 ms 後。保存設定(`save.*`)の変更では再描画しない(画像の画素は保存設定に依存しないため)。`skipRows` の変更は Python を呼ばず、データ確認表のグレー表示だけ即時に更新する。 +- 描画は設定変更の 250 ms 後(デバウンス)。読込は、ファイル選択・ヘッダ変更・シート変更・桁区切り・小数点・日時の自動認識の変更で 0 ms、入力欄(区切り文字・ヘッダより前に読み飛ばす行数・コメント記号)の変更で 450 ms 後(`bridge.js` の `LOAD_DELAY_MS` / `SLOW_LOAD_PATHS`)。読込設定(`load.*`)の変更は全ファイルを読み直す。ファイル一覧のシート変更は、そのファイルだけを読み直す(`load.files` の変更に `fileId` が付く)。保存設定(`save.*`)の変更では再描画しない(画像の画素は保存設定に依存しないため)。`skipRows` の変更は Python を呼ばず、データ確認表のグレー表示だけ即時に更新する。 - 起動中の `#progress`(`aria-live="polite"`)は段階表示: 「Python 実行環境を読み込み中…」→「ライブラリを読み込み中…(pandas ✓, matplotlib …)」→「ライブラリを初期化中…」→ 準備完了で消える(フォント取得の表示が続く場合あり)。`` の `data-startup-stage`(`runtime` / `packages` / `init` / `ready`)に現在の段階が入る。新しい id は無い。読込失敗の案内(`data-load-failed`)は上書きしない。 - 保存設定(`save.*`: ファイル名・形式・背景透過・DPI・SVG の文字)の変更は、GUI 同期のときだけ、250 ms 後に `script` API でコードだけを作り直す(`bridge.js` の `refreshScript`)。画像は描き直さず、コードの世代(`#customPyCode` の `data-generation`)は表示中の画像のまま(`code-state.getGeneratedGeneration()`)。描画が予約中・実行中のときは何もしない(描画が新しいコードを返す。実行中だったら終了後にもう一度確かめる)。コードの更新に失敗したら(保存設定の不正など)エラーを表示し、次に成功したときに直近の描画成功のステータスへ戻す。編集モードでは何もしない。 - 描画要求には世代番号を付ける。Python の描画は同時に最大1つ。実行中に新しい要求が来たら「やり直し」の印だけ付け、終了後に最新の設定で1回だけ描く(合流)。古い世代の結果は画像にもステータスにも反映しない。 - Python の起動前に選んだファイルや変えた設定は保持し、起動後に最新の内容で1回読み込んで描く(起動キュー)。 +- ファイルの操作(Phase 4): 「ファイルを選択」`#fileInput`(複数可)と貼り付けは、読み込み済みのデータをすべて置き換える(Python 側は `clearSources` を呼んでから読む)。「+ ファイルを追加」`#addFileBtn`(`#addFileInput`)とドロップの追加区画は追加。同じ名前のファイルは、その id のまま置き換えて読み直す。上限 10 個(超えた分は警告「読み込めるファイルは10個までです。…」で読み込まない)。削除(一覧の `file--remove`)は `removeSource` を呼び、そのファイルを使っていた系列は `source` を `""`、X / Y を自動に戻して警告を出す。最後のファイルを削除したら、起動直後の状態(図・データ確認・コード・ステータスを空にし、ファイルなし)に戻す(編集モードのコードはそのまま)。一部のファイルの読込に失敗しても、ほかのファイルは使える(失敗したファイルは一覧に残り、設定を変えると再試行する。それを使う系列はデータ元のエラーになる)。 +- ドロップ: ページ全体で受ける(`dataTransfer.types` に `Files` があるときだけ `#dropOverlay` を出す。文字列のドラッグには反応しない)。未読込のとき `#dropLoad` の1区画、読み込み済みのとき `#dropReplace`(置き換え)と `#dropAdd`(追加)の2区画で、ポインタの下の区画で決める(区画の外は置き換え)。ドラッグ中の preventDefault でブラウザがファイルを開かない。`dragleave` の `types` が空のブラウザ(Safari)でも出しっぱなしにしない。Esc でも閉じる。フォルダは読み込めない(警告)。 +- 貼り付け: `#pasteArea` への貼り付け(paste、または値が入った input)は常に読み込み、すべてのデータを置き換える。ページ上の paste(貼り付け先が input / textarea / select / contenteditable でないとき)は、データが未読込のときだけ読み込む。読込済みなら置き換えず、「「表を貼り付け」欄に貼り付けてください」と案内する警告を出す(うっかり置き換えない)。表とみなす条件は、タブを含む、または空でない行が2行以上。そうでなければ警告。改行は `\n` にそろえ、UTF-8 の `pasted_data.tsv` として `loadFile`(`pasted: true`)に渡す。`#savePastedBtn` は、データ元に貼り付けたデータがあるときだけ表示し、同じバイト列を JS だけでダウンロードする。 +- コードタブの「最新でない」印: GUI 同期のとき、描画またはコードだけの更新(`refreshScript`)が失敗して新しいコードを作れなかったら、`#customPyCode` に `data-stale="true"` を付け、`#codeStaleNote`(既定は hidden)を出す。新しいコードを表示したとき・編集モードに入ったときに消える(`code-state.js` の `setCodeStale`)。既存の data 属性の値は変えない。 - Pythonコードタブのモード(`js/code-state.js`): - `sync`: GUI の設定から生成したスクリプトを表示し、描画に成功するたびに更新する。 - `edit`: 利用者が書き換えたコードを「実行」(Ctrl/Cmd+Enter)したときだけ描く。GUI の変更でも、ファイルの読込でも自動では描かず、コードも更新しない(ステータスに warning で「編集中のため反映されません」と出す)。読込は行い、ファイルは作業フォルダに置かれる。フォント取得後の描き直しも、実行したことがあるときだけ行う。 - 「編集」で `edit`、「GUI から再生成」で `sync` に切り替える。切り替えのたびに、予約中・実行中の描画を無効にする(世代を進め、結果は画像にもコードにもステータスにも反映しない)。このため `data-render-generation` は、モード切替の直後に、表示中の画像の世代より先に進んだままになることがある。E2E は `tests/e2e/helpers.py` の専用ヘルパー(`wait_code_generated`、`wait_image_generation_changed`、`run_edited_code`、`wait_run_finished`)で待つこと。 - `sync` の描画は読込済みデータが必要。`edit` は Python が動いていればよい(コードが自分でファイルを読む)。 - 欧文フォント(`plot.latinFont`): 選んだときだけ取得する(既定の `default` では取得しない)。Python の起動前に選んでいたら、起動後に取得する。取得中の描画・保存・コピーは、`waitForFonts` が最大 `FONT_FIRST_RENDER_WAIT_MS`(8 秒)待つ。超えたらその種類は待ちを打ち切り、標準フォントで描き、取得・登録が済んだら(選択中のままなら)現在の設定で1回だけ描き直す(編集モードでは「実行」したことがあるときだけ)。取得は種類ごとにセッション中1回で、失敗しても再試行しない。失敗したら、選択中のあいだ警告「欧文フォント Arimo(または Tinos)を取得できなかったため、標準のフォントで描画しています。」を常時警告に1度だけ出し、標準(`default`)に戻すと消える(取得を再試行しない)。編集モードでも欧文フォントの取得・登録はするが、描画もコードも触らない。取得中は進捗バナーに「欧文フォントを取得中…」。 -- 左パネルは `details.panel-section`(`#sectionLoad` データ読み込み、`#sectionSeries` 系列、`#sectionAxes` 軸・目盛、`#sectionStyle` 体裁、`#sectionSave` 保存)に分かれ、既定はすべて開く。折りたたみの状態は localStorage の `mplgui.panelSections` に持つ(`panelSections.js`)。ステータス(`#status`)は区分の外にあり、閉じていても見える。「注釈・解析」の区分は Phase 5 まで作らない。体裁区分は、プリセット(`#stylePreset`、「適用」`#applyPresetBtn`)、`#colorPalette`、`#latinFont`、`#fontSize`、図サイズ(`#figWidth` / `#figHeight` / `#figUnit`)、凡例位置、グリッド、余白。軸・目盛区分には数式の案内(`#mathtextHelp`。matplotlib 3.10.8 の mathtext ページへのリンク)がある。保存区分は `#saveDpi`(+ 任意の `#saveDpiCustom`)、SVG のときだけ `#svgTextGroup`(`#svgText`)、PDF のときだけ `#pdfHelp`、`#savePlotBtn`、`#copyPlotBtn`。 +- 左パネルは `details.panel-section`(`#sectionLoad` データ読み込み(ファイル・貼り付け・シート・区切り文字・ヘッダ・読み飛ばす行数・桁区切り・小数点・コメント記号・日時の自動認識)、`#sectionSeries` 系列(描画から除外する先頭行数を含む)、`#sectionAxes` 軸・目盛、`#sectionStyle` 体裁、`#sectionSave` 保存)に分かれ、既定はすべて開く。折りたたみの状態は localStorage の `mplgui.panelSections` に持つ(`panelSections.js`)。ステータス(`#status`)は区分の外にあり、閉じていても見える。「注釈・解析」の区分は Phase 5 まで作らない。体裁区分は、プリセット(`#stylePreset`、「適用」`#applyPresetBtn`)、`#colorPalette`、`#latinFont`、`#fontSize`、図サイズ(`#figWidth` / `#figHeight` / `#figUnit`)、凡例位置、グリッド、余白。軸・目盛区分には数式の案内(`#mathtextHelp`。matplotlib 3.10.8 の mathtext ページへのリンク)と、X が日時の列のときだけ出る日時の書式(`#xDateFormatGroup`。line / scatter はどれかの系列の X、bar は `#xColumn` が日時の列のとき)がある。保存区分は `#saveDpi`(+ 任意の `#saveDpiCustom`)、SVG のときだけ `#svgTextGroup`(`#svgText`)、PDF のときだけ `#pdfHelp`、`#savePlotBtn`、`#copyPlotBtn`。 - 図サイズの単位を替えると、入力欄の数値を換算し(`convertLength`。空欄・不正な入力はそのまま)、図の大きさは変えない。ラベル(`図幅(inch)` など)と step は単位に合わせる。プリセットの「適用」は値を書き込むだけで、描画は通常の変更どおり。パレットを替えると、色の選択肢と各系列の色を描き直す。 - 「クリップボードにコピー」(`clipboard.js`): 保存 DPI の PNG を `copyImage` で作り、`ClipboardItem` に `Promise` として渡して `navigator.clipboard.write` する(Safari がクリックの中での呼び出しを要求するため、クリックの処理では何も `await` せずに `write` を呼び、画像は渡した Promise の中で作る)。対応は `window.isSecureContext`(https または localhost)、`navigator.clipboard.write`、`ClipboardItem`(`ClipboardItem.supports("image/png")` があれば true)。編集モードでは編集中のコードの図をコピーする。 -- E2E が使う状態は `` の data 属性: `data-app-state`(`starting` / `ready` / `failed`)、`data-data-state`(`none` / `loading` / `ready` / `error`)、`data-render-state`(`idle` / `pending` / `rendering`)、`data-render-generation`、`data-font-state`(日本語フォント。`idle` / `loading` / `ready` / `failed`)、`data-latin-font-state`(選択中の欧文フォント。`idle`(標準を選択中)/ `loading` / `ready` / `failed`)、`data-copy-state`(`idle` / `copying` / `done` / `failed` / `unsupported`)、`data-load-count`。表示中の画像は `#plotArea img` の `data-generation` と `data-summary`(`figure_summary` の JSON)を持つ。ステータスは `#status` の `data-kind`。Pythonコードタブ用に、`` の `data-code-mode`(`sync` / `edit`)、`#customPyCode`(textarea)の `data-generation`(表示中のコードを生成した世代。GUI 同期のとき、同じ世代の画像と対応する)、`#codeOutput` の `data-kind`(`ok` / `error`)、`#codeSyncStatus` の `data-state`(`sync` / `edit`)がある。 +- E2E が使う状態は `` の data 属性: `data-app-state`(`starting` / `ready` / `failed`)、`data-data-state`(`none` / `loading` / `ready` / `error`)、`data-render-state`(`idle` / `pending` / `rendering`)、`data-render-generation`、`data-font-state`(日本語フォント。`idle` / `loading` / `ready` / `failed`)、`data-latin-font-state`(選択中の欧文フォント。`idle`(標準を選択中)/ `loading` / `ready` / `failed`)、`data-copy-state`(`idle` / `copying` / `done` / `failed` / `unsupported`)、`data-load-count`(読み込めたデータ元がある読込のまとまりの完了ごとに増える。ファイルを外しただけ・全ファイルの失敗では増えない)。複数ファイルのとき、`data-data-state` は読込中が1つでもあれば `loading`、読込中がなく失敗が1つでもあれば `error`、すべて成功なら `ready`、ファイルが無ければ `none`。表示中の画像は `#plotArea img` の `data-generation` と `data-summary`(`figure_summary` の JSON)を持つ。ステータスは `#status` の `data-kind`。Pythonコードタブ用に、`` の `data-code-mode`(`sync` / `edit`)、`#customPyCode`(textarea)の `data-generation`(表示中のコードを生成した世代。GUI 同期のとき、同じ世代の画像と対応する)、`#codeOutput` の `data-kind`(`ok` / `error`)、`#codeSyncStatus` の `data-state`(`sync` / `edit`)、`#customPyCode` の `data-stale`(最新でないときだけ `true`)、`#codeStaleNote` がある。Phase 4 の画面の id: データ読み込み区分の `#fileInput` / `#addFileBtn` / `#addFileInput` / `#fileList` / `#pasteArea` / `#savePastedBtn` / `#sheetGroup` / `#sheetSelect` / `#skipLines`(+ `#skipLinesHelp`)/ `#thousands` / `#decimal` / `#commentChar` / `#parseDates`、系列区分の `#skipRowsHelp` / 系列カードの `series--source`、軸・目盛区分の `#xDateFormatGroup` / `#xDateFormat` / `#xDateFormatCustom`、データ確認の `#dataPreamble` / `#previewSource`(+ `#previewSourceGroup`)、ドロップの `#dropOverlay` / `#dropLoad` / `#dropReplace` / `#dropAdd`。 ## 6. 起動とフォント @@ -167,16 +197,19 @@ Python が登録し、登録後に `mplgui-ready` イベントを送る。引数 - 文字コード(`loader.detect_encoding`): BOM 付きなら utf-8-sig → UTF-8 → CP932 と EUC-JP。両方でデコードできるときは、半角カナ・私用領域・制御文字などの「文字化けらしい文字」が少ないほうを採用し、同数なら CP932。どれでも読めなければ日本語のエラーにする。判定結果は `#encodingLabel` に表示する。 - 数値に変換できず除外した値は、系列ごとに警告にする(`dataprep.dropped_warning`。`dataprep.plan_plot` が集める)。 +- 数値に変換できず除外した値の例が「1,234」のような桁区切りの数で `thousands` がカンマでないとき、「1,5」のような小数点カンマで `decimal` がカンマでないときは、警告の文に「(桁区切りのカンマなら、「データ読み込み」の「桁区切り」をカンマにしてください)」「(小数点がカンマなら、「小数点」をカンマにしてください)」を添える(`dataprep.load_hint`。両方に当てはまる `1,234` は桁区切りのヒントだけ)。 +- 日時の列の認識(`loader.detect_datetime_columns`。`load.parseDates` が true のときだけ。Excel の日付セル(もともと datetime の列)は設定にかかわらず日時): 読込後に数値でない文字列の列のうち、空欄以外の半数以上が数値に読める列は除く。書式は年が先頭の固定の候補だけ(日付 `%Y-%m-%d` / `%Y/%m/%d` / `%Y年%m月%d日`、時刻は無し / ` %H:%M` / ` %H:%M:%S` / ` %H:%M:%S.%f`、`%Y-%m-%d` には ISO の `T` 区切りも、時刻のあるものには末尾 `%z`(`Z` と `+09:00`)も)。前後の空白を除いた先頭 100 個の空欄以外の値の 90% 以上が読める書式のうち最も多く読めるものを選び、列全体でも 90% 以上が読めれば日時の列にする。月日の順が曖昧な `01/02/2024`、時刻だけ、年月だけは認識しない。読めない値は NaT にして、列ごとに警告(「列「name」[i]: 日時として読めない値がN件あったため、欠損として扱います(例: …)」。例は最大3件)。`%z` 付きは、全行が同じオフセットなら書かれた時刻のまま、混在していれば UTC にそろえて警告する。日時の列は `columns[].kind` が `datetime` になり、データ確認の列見出しに「日時」の印が付く。日時の X で描くときの検査は §4.1。 - ステータス(`#status`)の種別は `ok` / `warning` / `error`。警告は成功メッセージに添えて表示し、`error` のときは `data-kind="error"`。日本語フォント取得失敗のような常時有効な警告は、以後の表示にも添える。欧文フォントの取得失敗の警告は、そのフォントを選んでいるあいだだけ常時警告に出し(1度だけ)、選び直すと消える(`removeStickyWarning`)。 - コピーのメッセージ: 成功は `ok`「図をクリップボードにコピーしました(PNG、{dpi} dpi、{幅} × {高さ} ピクセル)。」、非対応のブラウザは `warning`「このブラウザでは図をクリップボードにコピーできません。「保存する」で PNG を保存してください。」、ブラウザが書き込みを拒否したときは `error`「クリップボードにコピーできませんでした。ページをクリックしてから、もう一度試してください。」。Python 側の失敗(データ無し、保存設定の不正、画素数、数式など)は保存と同じ表示で、上の拒否のメッセージは重ねない。 ## 8. 警告フィルタ -無視する警告は、次の3つだけ。それ以外の警告は無視しない(A10)。 +無視する警告は、次の4つだけ。それ以外の警告は無視しない(A10)。 - pandas の pyarrow 関連の `DeprecationWarning`(`runtime.configure_warnings`、起動時) - matplotlib のグリフ不足の `UserWarning`(`runtime.configure_warnings`、起動時) - `plt.show()` が Agg で出す `UserWarning`(`FigureCanvasAgg is non-interactive, and thus cannot be shown`)。生成スクリプトも `plt.show()` で終わるため、`runner._execute` の中だけ、`warnings.catch_warnings()` の範囲で無視する(スクリプトの実行が終われば元に戻る。グローバルには設定しない)。 +- 区切り文字のエスケープ表記(`loader._decode_escapes`)で、`\d` のような未定義のエスケープを `unicode_escape` で解釈するときに出る `DeprecationWarning`(`... is an invalid escape sequence`)。文字はそのまま残る。`warnings.catch_warnings()` の中で、この種類とメッセージだけを無視する。 ## 9. parentNode エラー @@ -210,6 +243,12 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 実フォントを jsDelivr から取得するテストがある(`test_latin_font.py` の一部、`test_save_section.py` の PDF 埋め込みなど。インターネット接続が必要)。`test_font.py`(日本語フォントの失敗・停止・遅延・キャッシュ)は、matplotlib 同梱の小さな TTF を `page.route` で代役にして、5.7 MB の取得を避ける。 - クリップボードのテストは `page.context.grant_permissions(["clipboard-read", "clipboard-write"])` を許可してから、`navigator.clipboard.read()` で画像を読み戻す。 - `helpers.py` の Phase 3 用ヘルパー: `natural_size`、`act_and_wait_render`、`series_color`、`palette_grid_colors`、`ARIMO_GLOB` / `TINOS_GLOB`、`image_size`、`save_bytes`、`set_save_dpi`、`latin_font_state` / `wait_latin_state`、`copy_state` / `wait_copy_state`、`read_clipboard_png_size`、`wait_code_contains`。 +- Phase 4 で加えた単体テスト: + - `test_load_options.py`: `skip_lines` / `thousands` / `decimal` / `comment`(csv・xlsx)、前置き行(上限20行)と、指定漏れの ParserError のヒント、xlsx のシート(先頭・無い名前・`ExcelFile` の再利用と id ごとのキャッシュ)、区切り文字との衝突、区切り指定漏れの警告、decimal == 区切り文字の警告(引用符付きの値は読める)、`read_kwargs` が loader と codegen の共通定義であること、生成した読込部と loader の結果の一致、すべての読込引数が明記されること、シート名・ファイル名・コメント記号の注入、A7 のヒント、`split_lines`。 + - `test_datetime.py`: 書式ごとの認識、タイムゾーン(同一オフセット・`Z`・混在で UTC)、前後の空白、90% の境界と先頭 100 個、書式の選び方、警告(例は最大3件)、数値っぽい文字列・曖昧な書式は認識しない、`parseDates` false と Excel の日時セル、loader と生成コードの変換式の一致、日時軸・日時の棒グラフ・文字列の X(25 個超で間引き)の生成コード、`mdates` / `ticker` の import が必要なときだけであること、日時の書式と列名の注入、1万行の読込。 + - `test_multi_source.py`: `Session` の追加・置き換え・削除・全消去と作業フォルダ、一部の失敗、同名ファイル、データ元の解決とエラー、`plan_plot` のデータ元ごとの処理(`skipRows`、Y の自動割り当て、棒グラフは同じデータ元、日時の規則)、2ファイルのスクリプト(`DATA_FILE_N` / `dfN`、使うファイルだけ、番号は位置)、自動描画用の注入、悪意のあるファイル名。`test_paste_tsv.py`: `.tsv`(タブ区切り・空白を含む値・区切りの上書き)と、貼り付けたデータの読込部・使い方のコメント。`test_settings.py`: v3 の既定値と検証、v2 → v3 の移行(`parseDates` false)。`test_js_modules.py`: 新しい系列の `source` が空、`version` が 3。 +- Phase 4 で加えた E2E: `test_drop_paste.py`(ドロップの読込とオーバーレイの出入り、文字列のドラッグは無反応、複数ファイル、未対応拡張子、`.tsv`、貼り付け(`#pasteArea`・input・ページ上)、表でない内容の警告、貼り付けたデータのコードと保存、編集モードで `pasted_data.tsv` を読めること、読込済みのときのページ上の貼り付けは置き換えず警告・`#pasteArea` は置き換え)、`test_load_options.py`(シート切替、前置き行の表示、ヨーロッパ形式の数値、コメント記号、桁区切りの警告とヒント、コードの追従、編集モードで読込設定を変えてもコードが変わらない、「最新でない」印)、`test_datetime.py`(日時軸、書式のプリセットと任意、不正な書式、X の範囲指定のエラー、`parseDates` を外す、日本語の日付の棒グラフ、Excel の日時セル、日時とそれ以外の X の混在、タイムゾーン混在の警告)、`test_multi_files.py`(一覧とデータ元の表示、2ファイルの系列、データ元の切替、データ確認の切替、削除と系列の戻し、置き換え、同名、上限、ドロップの追加・置き換え区画、一部の失敗と再試行、シート変更は該当ファイルだけ、編集モードで2ファイルを読む、貼り付けは全置き換え、全削除で初期状態、列が変わらない読込で系列カードを保つ)。`test_codegen_consistency.py` は、前置き行、ヨーロッパ形式、日時の線、xlsx の日時、2枚目のシート、2つの CSV、CSV と xlsx の2枚目、貼り付けたデータの各ケースも一致確認の対象にした。 + - `helpers.py` の Phase 4 用ヘルパー: `change_load_option` / `set_load_options`、`sheet_options`、`code_is_stale`、`drag_event` / `drop_files` / `drop_overlay_visible`、`paste_text`、`add_files`、`file_row_ids` / `file_row_states`、`settings_files`、`add_series_with_source`、`remove_file`。 - E2E は要素を id だけで選ぶ。`index.html` の id は安定させ、変えるときはテストも直す。アプリ固有の待機・操作は `tests/e2e/helpers.py` にまとめる。 - テストデータは `tests/fixtures/` の合成データだけ(公開リポジトリなので実データを置かない)。 - テスト用サーバー(`tests/e2e/server.py`)は listen backlog を 128 に広げている。既定の 5 だと、Chromium が ES モジュールを一斉に取得したときに接続が切れて、アプリが起動しないことがある。 @@ -219,7 +258,7 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - CI(`.github/workflows/tests.yml`): push で単体テスト、プルリクエストで E2E。E2E は Jekyll でビルドした `_site` を `/matplotlib_gui/` 配下で配信して検証する。 - Jekyll は `_` で始まるファイルを既定で配信しない。`__init__.py` は `_config.yml` の `include` で明示している。開発用ファイル(`docs/`、`tests/`、`AGENTS.md`、`pyproject.toml`、`uv.lock`、`.github/` など)は `exclude`。 -- `test_cdn_pinning.py` は、フォント(`@expo-google-fonts/*@x.y.z`、`@fontsource/*@x.y.z`)の jsDelivr URL と、バージョン付きのパスの `matplotlib.org`(取得しない説明リンク)を許可する。新しい JS モジュール(`js/units.js` など)は静的ファイルとしてそのまま公開される(`pyscript.toml` は Python ファイルだけ)。 +- `test_cdn_pinning.py` は、フォント(`@expo-google-fonts/*@x.y.z`、`@fontsource/*@x.y.z`)の jsDelivr URL と、バージョン付きのパスの `matplotlib.org`(取得しない説明リンク)を許可する。新しい JS モジュール(`js/units.js`、`js/ui/dropPaste.js`、`js/ui/fileList.js` など)は静的ファイルとしてそのまま公開される(`pyscript.toml` は Python ファイルだけ)。Phase 4 では新しい Python モジュールも CDN も足していない(`pyscript.toml` と CI の変更なし)。 - 新しい Python モジュールを追加したら、`pyscript.toml` の `[files]` に必ず追加する(`tests/unit/test_pages_publish.py` が参照ファイルの公開を確認する)。 - matplotlib 3.10 では `tight_layout()` 後に `PlaceHolderLayoutEngine` が残り、`savefig` が余分に全体を再描画する。`runner.drop_placeholder_layout_engine` がこれを外している(出力は同一)。 @@ -237,11 +276,12 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 生成スクリプトの最後は `fig.savefig(...)`(GUI の「保存」と同じ形式・解像度)と `plt.show()`。 - 編集は明示的な「編集」ボタンで始める。`useCustomCode`(「使用する」)は廃止した。編集中は GUI の変更で、コードも図も変わらない。 - 棒グラフ(1系列・複数系列)は、カテゴリの目盛ラベルを保つ(Phase 1 の不具合を修正)。複数系列は、棒の位置にラベルを付ける(25個を超えたら間引く)。 -- line / scatter で X が数値でない列(文字列・日時)のとき、Phase 1 と同じ見た目を保つため、生成コードに `ax.set_xscale("linear")` を入れる(目盛は位置の番号になる)。D5(日付軸)で見直す(Phase 4)。 +- line / scatter で X が数値でない列(文字列・日時)のとき、Phase 1 と同じ見た目を保つため、生成コードに `ax.set_xscale("linear")` を入れる(目盛は位置の番号になる)。D5(日付軸)で見直す(Phase 4)。→ Phase 4 でやめた(§13.3)。 - ファイル選択欄に、選んだファイル名を欄の中に表示する(`#fileNameLabel`。`input` は選択のたびに空に戻すため、JS が表示する)。 ### 13.2 Phase 3(2026-10-08) +- 手動確認(2026-10-08、オーナーが確認。いずれも問題なし): Illustrator で PDF の文字を編集できること。Safari と Firefox の実機で、クリップボードへのコピーが動くこと。 - 保存 DPI の既定値は 300(U1)。選択肢は 72 / 150 / 300 / 600 と任意の値。プレビューは今までどおり dpi 100 で描く。 - 図サイズの既定の単位は inch(8×6 を維持。U2)。cm と mm も選べる。単位を切り替えたら入力欄の数値を換算する(図の大きさは変えない。例: 8 inch → 20.32 cm)。設定には「選んだ単位の値」と単位を持つ(インチに直して持たない)。 - 欧文フォントは Arimo(Arial 互換)と Tinos(Times 互換。U3)。ライセンスはどちらも SIL OFL 1.1(要件定義書の当初の「Apache 2.0」は誤り)。選んだときだけ、バージョン固定の jsDelivr から取得する。日本語の文字は Noto Sans JP で描く(matplotlib の文字単位のフォールバック)。Tinos のときは数式(mathtext)を STIX にする。 @@ -253,3 +293,17 @@ uv run pytest tests/e2e # E2E(Chromium とローカルの静的サー - 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に整理し、各区分を `
` で折りたためるようにする。折りたたみの状態は localStorage に保持する。「注釈・解析」の区分は Phase 5 まで作らない。 - 設定スキーマを version 2 にした。v1 からの移行では、v1 の保存が常に 120 dpi だったため `save.dpi` を 120 にする。 - 「描画設定: 先頭からスキップする行数」の欄名を「描画から除外する先頭行数」に改めた(「描画設定」の区分が無くなったため)。 + +### 13.3 Phase 4(2026-10-08) + +- D6(複数ファイル)も Phase 4 に含める。読込設定(区切り文字、ヘッダ、読み飛ばす行数、桁区切り、小数点、コメント記号、日時の認識)は全ファイル共通で、シートだけファイルごとに選ぶ。棒グラフは、すべての系列が同じデータ元のときだけ描ける。同じ名前のファイルは置き換えとして扱う(作業フォルダと生成コードでファイルを名前で区別するため)。読み込めるのは 10 個まで。 +- 設定スキーマを version 3 にした。v2 からの移行では `load.parseDates` を false にする(v2 までの出力を保つため。新しく作る設定の既定値は true)。 +- ドラッグ&ドロップはページ全体で受ける。データを読み込み済みのときは、「置き換えて読み込む」「追加して読み込む」のどちらにドロップするかで選ぶ。 +- 貼り付けたデータは `pasted_data.tsv`(UTF-8、タブ区切り)というファイルとして扱い、生成コードはこのファイルを読む(データはスクリプトに埋め込まない)。「貼り付けたデータを保存」で同じ内容を保存し、スクリプトと同じフォルダに置いてもらう。あわせて `.tsv` を対応する拡張子に加えた(既定の区切り文字はタブ)。 +- 「ヘッダより前に読み飛ばす行数」は「データ読み込み」の区分に、「描画から除外する先頭行数」は「系列」の区分に置き、補足で何を数えるかを示す。読み飛ばした行は、データ確認タブの表の上に枠で囲んで表示する(描画から除外する行のグレー表示とは見た目を分ける)。 +- 桁区切り・小数点・コメント記号は pandas の `thousands` / `decimal` / `comment` で扱い、生成コードの読込部には、すべての読込引数を既定値も含めて明記する。区切り文字の自動判定は加えない(指定漏れが疑われるときは警告で案内する)。 +- 日時の列の認識(規則は §7): 年が先頭の書式だけ。月日の順が曖昧な書式、時刻だけ、年月だけは認識しない。読めない値は欠損にして警告する。タイムゾーンは、同じオフセットなら書かれた時刻のまま、混在していたら UTC にそろえる(警告する)。Excel の日付セルは常に日時として扱う。 +- 日時の X 軸の目盛は、自動(`AutoDateLocator` と `ConciseDateFormatter`)か、指定した書式(`DateFormatter`)にする。X が日時のときは、X 軸の対数と範囲の指定はできない(日本語のエラーで案内する)。棒グラフで X が日時のときは、表示書式で文字列にしてカテゴリとして並べる。 +- Phase 2 で入れた `ax.set_xscale("linear")`(X が数値でない列のとき)はやめた。日時の列は日時軸にし、それ以外の文字列の列は目盛に値を出す(25個を超えるときは間引く)。 +- Pythonコードタブが GUI 同期のとき、エラーで最新の設定のコードを作れなかったら、表示中のコードが最新でないことをタブ内に表示する(`#codeStaleNote`)。 +- レビュー後の決定: 小数点と区切り文字が同じ記号のときはエラーにせず警告にする(引用符で囲まれた値は読めるため)。ページ上の貼り付け(`#pasteArea` 以外)は、読み込み済みのデータを置き換えない(未読込のときだけ読み込む。置き換えは「表を貼り付け」欄で行う)。最後のファイルを削除したら、起動直後の状態に戻す。 diff --git a/README.md b/README.md index 37ac44e..9924afa 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,29 @@ # Matplotlib GUI -CSV / TXT / XLSX のデータから、ブラウザ上の操作だけで matplotlib の図を作るツールです。サーバーを使わず、Python(PyScript / Pyodide)をブラウザの中で実行します。 +CSV / TSV / TXT / XLSX のデータから、ブラウザ上の操作だけで matplotlib の図を作るツールです。サーバーを使わず、Python(PyScript / Pyodide)をブラウザの中で実行します。 ## 使い方 https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` を `file://` で直接開く使い方には対応していません。 -1. 「入力ファイル」で `.csv` / `.txt` / `.xlsx` を選びます。 +1. 「入力ファイル」で `.csv` / `.txt` / `.tsv` / `.xlsx` を選ぶか、ページにファイルをドラッグ&ドロップします。Excel などでコピーした表は、「表を貼り付け」欄に貼り付けても読み込めます。 2. 読み込みと描画は自動で行われます(「描画する」ボタンはありません)。設定を変えると、すぐ再描画されます。 -3. 「描画系列」でX列・Y列・色などを選び、必要なら系列を追加します。 +3. 「描画系列」でX列・Y列・色などを選び、必要なら系列を追加します。複数のファイルを読み込んだときは、系列ごとに「データ元」を選びます。 4. 「体裁」でフォント・図のサイズ・プリセットなどを整えます。 5. 「保存」でファイル名・形式・DPI を選び、「保存する」で書き出すか、「クリップボードにコピー」で他のアプリに貼り付けます。 左の設定パネルは「データ読み込み」「系列」「軸・目盛」「体裁」「保存」の区分に分かれていて、見出しを押すと折りたためます(折りたたみの状態は、このブラウザに覚えておきます)。 +### データの読み込み + +- ドラッグ&ドロップ: ページのどこにでも落とせます。すでにデータを読み込んでいるときは、「置き換えて読み込む」「追加して読み込む」のどちらかの枠に落として選びます。 +- 貼り付け: 貼り付けた表は `pasted_data.tsv`(UTF-8、タブ区切り)として扱います。「貼り付けたデータを保存(.tsv)」で保存でき、生成した `.py` を動かすときは、このファイルを同じフォルダに置きます。「表を貼り付け」欄に貼り付けると、読み込み済みのデータは置き換わります。 +- `.xlsx` は、複数のシートがあるとき「シート」で選べます(ファイルごとに選べます)。 +- 「ヘッダより前に読み飛ばす行数」: ファイルの先頭にある説明の行など、ヘッダ行より前の行を読み飛ばします。読み飛ばした行は、データ確認タブの表の上に枠で表示します。これとは別に、「系列」の「描画から除外する先頭行数」は、ヘッダの後のデータ行を描画から除外します(データ確認タブでグレーになります)。 +- 「桁区切り」「小数点」「コメント記号」: `1,234` や `1.234,5`(ヨーロッパ形式)、`#` で始まる説明の行などを読めます。数値に変換できない値があるときは、該当しそうな設定を警告で案内します。区切り文字は自動判定しないので、`;` やタブ区切りのファイルは「区切り文字」で指定してください。 +- 日時の列: `2026-01-31`、`2026/01/31 13:45`、`2026年1月31日`、ISO 8601(`2026-01-31T13:45:00+09:00`)など、年が先頭の書式の列を自動で日時として認識します(「日時の列を自動で認識する」で切り替え)。X に日時の列を選ぶと日時の軸になり、軸・目盛の区分で表示書式(`2024-01-31`、`01/31 13:45` など。任意の書式も可)を選べます。X が日時のときは、X軸の対数と範囲の指定はできません。棒グラフでは、日時を文字にして並べます。月日の順が曖昧な `01/02/2024` は認識しません。 +- 複数ファイル: 最大 10 個まで読み込めます(同じ名前のファイルは置き換え)。読込設定は全ファイル共通で、シートだけファイルごとに選びます。棒グラフは、すべての系列を同じデータ元にしたときだけ描けます。 + 対応する文字コードは UTF-8、UTF-8(BOM付き)、Shift_JIS(CP932)、EUC-JP です。判定した文字コードは、読み込み後に画面へ表示されます。 ## 主な機能 @@ -29,8 +39,8 @@ https://mioupa.github.io/matplotlib_gui/ を開いてください。`index.html` - 欧文フォント: 標準(Noto Sans JP)、Arimo(Arial 互換)、Tinos(Times 互換)。英数字と記号だけを選んだフォントで書き、日本語は Noto Sans JP で書きます(Tinos のときは数式も Times 系の字形になります) - クリップボードへのコピー: プレビュー中の図を、保存 DPI の PNG として画像でコピーします(背景透過は保存設定に従います)。対応ブラウザは Chrome / Edge 98 以降、Safari 13.1 以降、Firefox 127 以降で、https で開いている必要があります。非対応のときは「保存する」で PNG を保存してください - 数式・上付き・下付き: タイトル・軸ラベル・凡例名で、`$` で囲んだ部分が数式になります。例: `m$^2$`(上付き)、`H$_2$O`(下付き)、`$\alpha$`(ギリシャ文字)。`$` を文字として書くときは `\$` とします。書き方を間違えると、日本語のエラーが表示されます -- データ確認タブ: 先頭100行を表示し、描画から除外される先頭行をグレーで表示 -- Pythonコードタブ: 設定から生成した matplotlib のスクリプトを表示(下記) +- データ確認タブ: 先頭100行を表示し、描画から除外される先頭行をグレーで、読み飛ばした行(ヘッダより前)を枠で表示。複数ファイルのときは表示するファイルを切り替えられます +- Pythonコードタブ: 設定から生成した matplotlib のスクリプトを表示(下記)。エラーで最新の設定のコードを作れないときは、表示中のコードが最新でないことをタブ内に表示します - 数値に変換できず除外した値の警告、日本語のエラーメッセージ ## Pythonコードタブ diff --git a/css/style.css b/css/style.css index c55598e..a388a9f 100644 --- a/css/style.css +++ b/css/style.css @@ -491,6 +491,43 @@ button.secondary:hover { margin-bottom: 8px; font-size: 13px; } +#dataPreamble { + margin-bottom: 8px; + padding: 8px 10px; + border: 1px dashed var(--line); + border-radius: 8px; + background: var(--surface); + font-size: 12px; +} +#dataPreamble .preamble-title { + font-weight: 700; + margin-bottom: 4px; +} +#dataPreamble .preamble-lines { + margin: 0; + max-height: 9em; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre; + color: var(--text-muted); +} +#dataPreamble .preamble-more { + margin-top: 4px; + color: var(--text-muted); +} +.code-stale-note { + margin-bottom: 8px; + padding: 6px 10px; + border-left: 3px solid var(--status-warning); + border-radius: 6px; + background: var(--warning-bg); + color: var(--status-warning); + font-size: 12px; + font-weight: 700; +} +.code-stale-note[hidden] { + display: none; +} #codePanel { overflow: auto; } @@ -873,3 +910,144 @@ button.series-color-chip.active { font-size: 13px; white-space: nowrap; } + +/* 表の貼り付け欄とファイルのドロップ */ +.paste-area { + width: 100%; + box-sizing: border-box; + resize: vertical; + padding: 8px; + border: 2px dashed var(--line); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-size: 13px; +} +.paste-area:focus { + border-color: var(--accent); + outline: none; +} +#savePastedBtn { + margin-top: 8px; +} +.drop-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: stretch; + justify-content: center; + padding: 24px; + box-sizing: border-box; + background: var(--busy-bg); +} +.drop-overlay[hidden] { + display: none; +} +.drop-zones { + display: flex; + flex: 1; + gap: 24px; +} +.drop-zone { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + border: 3px dashed var(--accent); + border-radius: 16px; + background: var(--card); + color: var(--accent); + font-size: 22px; + font-weight: 700; + text-align: center; +} + +.col-kind-tag { + display: inline-block; + margin-left: 6px; + padding: 0 5px; + border: 1px solid var(--accent); + border-radius: 8px; + color: var(--accent); + font-size: 10px; + font-weight: normal; + line-height: 1.5; + vertical-align: middle; +} + +.drop-zone[hidden] { + display: none; +} +.drop-zone.drop-zone-active { + background: var(--accent); + color: var(--accent-contrast); +} +.add-file-row { + margin-top: 8px; +} +.file-list { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.file-list[hidden] { + display: none; +} +.file-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + padding: 6px 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--card); + font-size: 13px; +} +.file-row[data-state="error"] { + border-color: var(--error-border); +} +.file-row-no { + font-weight: 700; + color: var(--accent); +} +.file-row-name { + flex: 1 1 120px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-row-kind { + color: var(--text-muted); + font-size: 12px; +} +.file-row select { + max-width: 100%; +} +.file-row-error { + flex-basis: 100%; + color: var(--error-text); + font-size: 12px; + overflow-wrap: anywhere; +} +.file-row-loading { + color: var(--text-muted); + font-size: 12px; +} +.preview-source { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.preview-source[hidden] { + display: none; +} +.series-source-wrap[hidden] { + display: none; +} diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 482cddc..95881cc 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -232,3 +232,72 @@ Phase 1 後より悪化した項目はなかった。このため、悪化の内 - どれも、メモリ不足やクラッシュは起きなかった。コンソールエラーもない。画像の大きさは、ファイルを開いて指定どおりであることを確かめた。 - 上限付近では、RGBA の画素バッファ(1画素 4 バイト、100 MP で約 400 MB)が wasm のメモリに載るため、wasm メモリは約 600 MB まで広がった。wasm のメモリは縮まないので、保存後もこの大きさのまま残る。JS ヒープ(`performance.memory`)は約 93〜98 MB で変わらない。 - この Mac(24 GB)の結果。メモリの少ない端末(スマートフォンなど)では、600 MB 級の確保が失敗する可能性があるが、今回は確かめていない。上限(1億画素)の変更は不要と判断し、触っていない。 + +## Phase 4 後 + +### 計測条件 + +| 項目 | 内容 | +|---|---| +| 日付 | 2026-10-08 | +| 対象 | `c668457`(Phase 4: 読込設定(シート、ヘッダより前の読み飛ばし、桁区切り・小数点・コメント)、ドラッグ&ドロップと貼り付け、日時の列と日時軸、複数ファイル)に、Phase 4 の計測行を追加した `tests/perf/bench.py` を組み合わせたもの。PyScript 2026.7.3 / Pyodide 314.0.3 | +| マシン | Mac16,10 / Apple M4 / メモリ 24 GB | +| OS | macOS 27.0.1 | +| ブラウザ | Chromium 153.0.8010.12(Playwright、ヘッドレス) | +| ネットワーク | 実ネットワーク(Phase 3 後と同じ) | +| 配信 | ローカルの静的 HTTP サーバー(127.0.0.1、サブパスなし) | +| 反復 | 各項目 N=5。既存項目の計測方法は Phase 3 後と同じ | + +追加した項目(既存項目の方法は変えていない。データはすべて実行時に生成し、コミットしない): + +- 「貼り付け → 最初のプロット」: `#pasteArea` に `ClipboardEvent('paste')`(`DataTransfer` に text/plain)を送り、最初のプロットが出るまで(終了条件は「初回ファイル読込 → 最初のプロット」と同じ)。(a) 10,000 行 × 6 列(X + Y 5列)のタブ区切り、改行は CRLF(Excel からのコピーと同じ形)、(b) 30 行 × 3 列。 +- 「xlsx(2 シート × 10,000 行)」: openpyxl で作った 2 シート(各 10,000 行 × 6 列、約 0.8 MB)の xlsx。(a) 初回読込 → 最初のプロット(Excel 用ライブラリの wheel は HTTP キャッシュを温めた状態)、(b) 系列5本を設定してから `#sheetSelect` で 2 枚目に切り替え → 新しい画像(`change` イベントから。そのファイルの読み直しと描画を含む)。 +- 「日時の列を含む 10,000 行」: `%Y/%m/%d %H:%M:%S` の日時の列 + 数値 5 列の CSV。(a) 読込 → 最初のプロット(既定の系列で描く。日時の認識と変換を含む)、(b) X = 日時の列で系列5本を設定してから、既存の「設定変更」と同じ方法(タイトルの `input` → 新しい画像。デバウンス 250 ms 込み)。 +- 「2ファイル」: 10,000 行の CSV 2つを `#fileInput`(multiple)で同時に選ぶ → 最初のプロット。設定変更は、系列1 をファイル1、系列2 をファイル2(2 系列)にしてから、既存の「設定変更」と同じ方法で測る(5 系列の既存項目とは系列数が違うので、直接は比べられない)。 + +### 結果(ミリ秒、中央値 / 最小 / 最大) + +| 項目 | Phase 3 後 | Phase 4 後 | Phase 3 後比 | +|---|---:|---:|---| +| コールド起動 | 3,564 / 3,526 / 7,098 | 3,605 / 3,544 / 6,444 | 同等(+1%) | +| ウォーム起動 | 3,441 / 3,424 / 3,484 | 3,429 / 3,418 / 3,437 | 同等(-0%) | +| 初回ファイル読込 → 最初のプロット | 177 / 175 / 179 | 178 / 176 / 179 | 同等(+1%) | +| 設定変更 → プロット更新(デバウンス込み) | 471 / 468 / 473 | 465 / 457 / 470 | 同等(-1%) | +| 同上(デバウンス 250ms 除く) | 221 / 218 / 223 | 215 / 207 / 220 | 同等(-3%) | +| 保存(PNG、クリック → ダウンロード開始。既定の 300 dpi) | 405 / 364 / 408 | 384 / 377 / 387 | 同等(-5%) | +| 初回 .xlsx 読込 → 最初のプロット | 329 / 325 / 330 | 325 / 323 / 325 | 同等(-1%) | +| 編集モードでの実行(Ctrl/Cmd+Enter → プロット更新) | 579 / 575 / 596 | 571 / 561 / 582 | 同等(-1%) | +| 保存(PNG、120 dpi) | 246 / 242 / 257 | 252 / 215 / 258 | 同等(+2%) | +| 保存(PNG、300 dpi。別測定) | 403 / 369 / 412 | 403 / 393 / 409 | 同等(-0%) | +| 欧文フォント選択 → プロット更新、初回(Arimo を jsDelivr から取得) | 468 / 456 / 472 | 459 / 452 / 471 | 同等(-2%) | +| 同上、Cache Storage にあるとき | 467 / 462 / 469 | 466 / 453 / 471 | 同等(-0%) | +| 参考: 欧文フォント初回の、選択 → `data-latin-font-state=ready` | 23 / 21 / 25 | 34 / 24 / 38 | 参考値(+48%。下記) | +| クリップボードへのコピー(クリック → done、300 dpi) | 422 / 406 / 424 | 406 / 390 / 407 | 同等(-4%) | +| 日本語フォント取得、コールド(ナビゲーション開始 → `font-state=ready`) | 3,622 / 3,606 / 3,623 | 3,692 / 3,687 / 3,721 | 同等(+2%) | +| 同上、Python 準備完了からの追加待ち | 67 / 56 / 77 | 68 / 61 / 83 | 同等(+1%) | +| 貼り付け(10,000 行 × 6 列)→ 最初のプロット【新】 | - | 180 / 180 / 182 | - | +| 貼り付け(30 行 × 3 列)→ 最初のプロット【新】 | - | 132 / 131 / 135 | - | +| xlsx(2 シート × 10,000 行)初回読込 → 最初のプロット【新】 | - | 843 / 840 / 843 | - | +| xlsx のシート切り替え → プロット更新(系列5本)【新】 | - | 465 / 461 / 466 | - | +| 日時の列を含む 10,000 行の読込 → 最初のプロット【新】 | - | 238 / 234 / 240 | - | +| 同、X = 日時で設定変更 → プロット更新(デバウンス込み、系列5本)【新】 | - | 478 / 468 / 481 | - | +| 同上(デバウンス 250ms 除く)【新】 | - | 228 / 218 / 231 | - | +| 2ファイル(各 10,000 行)の読込 → 最初のプロット【新】 | - | 208 / 206 / 209 | - | +| 2ファイルで設定変更 → プロット更新(デバウンス込み、系列2本)【新】 | - | 342 / 337 / 345 | - | +| 同上(デバウンス 250ms 除く)【新】 | - | 92 / 87 / 95 | - | + +コールド起動の最大値は1回目の反復のみ(キャッシュが冷えていたため)。2回目以降は約3.6秒。 + +### 要件の確認 + +要件「設定変更後 1 秒以内の再描画(10,000 行 × 5 系列)」: 設定変更 → プロット更新は、デバウンス 250 ms を含めて中央値 465 ms(最大 470 ms)で、**満たしている**。X が日時の列のときも 478 ms(最大 481 ms)、xlsx のシート切り替え(読み直しを含む)も 465 ms で、どちらも 1 秒以内。 + +### 分析 + +Phase 3 後より 5% を超えて悪くなった既存項目は無い(日本語フォントのコールド取得の +2% は、jsDelivr からの実ダウンロードを測っているためのぶれの範囲)。Phase 4 の読込設定(読み飛ばし・桁区切り・小数点・コメント)は `read_csv` の引数が増えるだけで、日時の認識は文字列の列だけを調べるため、数値だけの CSV の読込(「初回ファイル読込」177 → 178 ms)には影響していない。 + +- **参考値「欧文フォント初回の選択 → ready」+48%(23 → 34 ms)**: jsDelivr から約 0.3 MB を取得する時間で、ネットワークのぶれ。デバウンス 250 ms の間に終わるため、プロット更新(459 ms)には影響していない(Phase 4 で欧文フォントの処理は変えていない)。 +- **日時の列を含む読込(238 ms)**: 数値だけの同じ大きさの CSV(178 ms)より約 60 ms 長い。日時の認識(文字列の列ごとに、先頭 100 個の値で書式の候補を試す)と、10,000 個の文字列の日時への変換の分。設定変更からの更新(228 ms、デバウンス除く)は数値の X(215 ms)より約 13 ms 長い程度で、日時軸の目盛の計算の分とみられる。 +- **貼り付け(10,000 行)180 ms**: 同じ大きさのファイルの読込(178 ms)とほぼ同じ。貼り付けたテキストは `pasted_data.tsv` というファイルとして、ファイルと同じ経路で読むため。 +- **xlsx(2 シート × 10,000 行)の初回読込 843 ms**: ワークブックの解析(openpyxl)が大半。シートの切り替えでは、開いた `ExcelFile` を同じファイルの間だけ使い回すので、ワークブックを開き直さず、そのシートの読込と系列5本の描画だけで 465 ms になる。 +- **2ファイル**: 読込はファイルごとに順に行う(208 ms。1ファイルの 178 ms に、2つ目の読込が約 30 ms 加わる)。設定変更からの更新は系列が2本なので、5本の既存項目より短い。 diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 2ae10ae..e1b94e3 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -48,6 +48,19 @@ - 左パネルを「データ読み込み」「系列」「軸・目盛」「体裁(フォント・サイズ・プリセット)」「保存」の区分に整理し、各区分を折りたためるようにする。「注釈・解析」の区分は、中身が入る Phase 5 まで作らない。 - 日本語フォントを、Noto Sans CJK JP(OTF、CFF 形式、約16 MB)から、同じデザインの TrueType 版 Noto Sans JP(約5.7 MB)に替える。matplotlib は CFF 形式のフォントを `pdf.fonttype=42` で正しく埋め込めない(TrueType 用の FontFile2 に CFF を入れ、PDF が壊れる)ため。文字幅は同じで、見た目は変わらない。ハングルと中国語専用の漢字は含まれない(JIS 第1〜第4水準は含む)。 +### 2.4 Phase 4 の決定事項(2026-10-08 にユーザーが決定) + +- D6(複数ファイル)も Phase 4 に含める。読込設定(区切り文字、ヘッダ、読み飛ばし行数、桁区切り、小数点、コメント記号、日時の認識)は全ファイル共通で、シートだけファイルごとに選ぶ。棒グラフは、すべての系列が同じデータ元のときだけ描ける。同じ名前のファイルは置き換えとして扱う(作業フォルダと生成コードでファイルを名前で区別するため)。読み込めるのは 10 個まで。 +- 設定スキーマを version 3 にする。v2 からの移行では、日時の列の自動認識(`load.parseDates`)を false にする(v2 までの出力を保つため。新しく作る設定の既定値は true)。 +- ドラッグ&ドロップはページ全体で受ける。データを読み込み済みのときは、「置き換えて読み込む」「追加して読み込む」のどちらにドロップするかで選ぶ。 +- 貼り付けたデータは `pasted_data.tsv`(UTF-8、タブ区切り)というファイルとして扱う。生成コードはこのファイルを読み、データはスクリプトに埋め込まない。「貼り付けたデータを保存」で同じ内容を保存し、スクリプトと同じフォルダに置いてもらう。あわせて `.tsv` を対応する拡張子に加える(既定の区切り文字はタブ)。 +- 「ヘッダより前に読み飛ばす行数」は「データ読み込み」の区分に、「描画から除外する先頭行数」は「系列」の区分に置き、それぞれ何を数えるかを補足で示す。読み飛ばした行は、データ確認タブの表の上に枠で囲んで表示する(描画から除外する行のグレー表示とは見た目を分ける)。 +- 桁区切り・小数点・コメント記号は pandas の `thousands` / `decimal` / `comment` で扱い、生成コードの読込部には、すべての読込引数を既定値も含めて明記する。区切り文字の自動判定は加えない。 +- 日時の列の認識: 数値でない列のうち、年が先頭の書式(`2026-01-31`、`2026/01/31`、`2026年1月31日` と、その時刻付き・ISO 8601 のタイムゾーン付き)で、先頭100個の90%以上かつ列全体の90%以上が読める列を日時とみなす。月日の順が曖昧な書式、時刻だけ、年月だけは認識しない。読めない値は欠損にして警告する。タイムゾーン付きは、同じオフセットなら書かれた時刻のまま、オフセットが混在していたら UTC にそろえる(警告する)。Excel の日付セルは常に日時として扱う。 +- 日時の X 軸の目盛は、自動(`AutoDateLocator` と `ConciseDateFormatter`)か、指定した書式(`DateFormatter`)にする。X が日時のときは、X 軸の対数と範囲の指定はできない(日本語のエラーで案内する)。棒グラフで X が日時のときは、表示書式で文字列にしてカテゴリとして並べる。 +- Phase 2 で line / scatter の X が数値でない列のときに入れていた `ax.set_xscale("linear")`(目盛が位置の番号になる)はやめる。日時の列は日時軸にし、それ以外の文字列の列は目盛に値を出す(25個を超えるときは間引く)。 +- Pythonコードタブが GUI 同期のとき、エラーで最新の設定のコードを作れなかったら、表示中のコードが最新でないことをタブ内に表示する。 + --- ## 3. 現状の構成(2026-10-07 時点) diff --git a/index.html b/index.html index d54c59f..5372775 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,13 @@ +
@@ -82,6 +141,7 @@

Matplotlib GUI

+
ヘッダの後のデータ行を数えます。除外する行はデータ確認タブでグレーになります。
@@ -152,6 +212,24 @@

Matplotlib GUI

+ +
+
+
diff --git a/js/bridge.js b/js/bridge.js index 4fb8f6a..a3959fd 100644 --- a/js/bridge.js +++ b/js/bridge.js @@ -5,20 +5,24 @@ // - Python の起動前に選んだファイル・変えた設定は保持され、起動後に最新の内容で1回読込・描画する。 // - Pythonコードタブのモード(code-state.js): sync は GUI の設定から生成したコードで描く。edit は利用者が編集したコードを // 「実行」したときだけ描き、GUI の変更では再描画しない(読込は行うが自動実行しない)。 -import { subscribe, toJson, getSettings } from "./state.js"; +import { subscribe, toJson, getSettings, setFileSheet, setPath, updateSeries } from "./state.js"; import { addStickyWarning, removeStickyWarning, setStatus, warningTexts } from "./ui/notify.js"; -import { showPlot } from "./ui/plotView.js"; +import { clearPlot, showPlot } from "./ui/plotView.js"; import { showCodeOutput } from "./ui/codeOutput.js"; -import { getGeneratedGeneration, isEditMode, setCodeMode, setGeneratedCode } from "./code-state.js"; -import { applySkipRows, showPreview } from "./ui/dataPreview.js"; -import { setColumns } from "./ui/series.js"; -import { showEncoding, showFileName } from "./ui/fileInfo.js"; +import { getGeneratedCode, getGeneratedGeneration, isEditMode, setCodeMode, setCodeStale, setGeneratedCode } from "./code-state.js"; +import { applySkipRows, clearPreview, showPreview } from "./ui/dataPreview.js"; +import { dropSourceColumns, reconcileColumns, refreshSources, setSourceColumns } from "./ui/series.js"; +import { encodingDisplayName, showEncoding, showFileName, showPastedSave } from "./ui/fileInfo.js"; +import { showSheets } from "./ui/loadSection.js"; +import { renderFileList, renderPreviewSource } from "./ui/fileList.js"; import { setBusy, showProgress } from "./ui/progress.js"; import { RUNTIME_MESSAGE, watchStartup } from "./startup-progress.js"; import { FONT_FIRST_RENDER_WAIT_MS, isFontCached, loadFont } from "./font-cache.js"; const RENDER_DELAY_MS = 250; const LOAD_DELAY_MS = { file: 0, header: 0, delimiter: 450 }; +const MAX_FILES = 10; // 読み込めるファイルの上限(settings.MAX_FILES と同じ) +const SLOW_LOAD_PATHS = new Set(["load.delimiter", "load.skipLines", "load.comment"]); // 入力欄(打鍵のたびに読み直さない) const FONT_FAILED_MESSAGE = "日本語フォントを取得できませんでした。日本語が正しく表示されない場合があります。"; const FONT_LOADING_MESSAGE = "日本語フォントを取得中…"; const LATIN_LOADING_MESSAGE = "欧文フォントを取得中…"; @@ -28,16 +32,21 @@ const SCRIPT_REFRESH_DELAY_MS = 250; const root = document.documentElement; let api = null; // window.mplgui(Python 側が登録) -let selectedFile = null; -let dataReady = false; +// 読み込んだファイル(データN)。id("d1", "d2", ...)をキーに、File・バイト列・貼り付けか・読込の状態と結果を持つ。 +// 並びと名前・シートは設定の load.files が持つ。state: loading | ready | error。error のときも、直前の成功時の result は残す(表示用)。 +const sources = new Map(); +let notes = []; // 直近のファイル操作に由来する警告(上限超え、削除で系列を戻したことなど)。読込・描画の警告に添える +const pendingLoad = new Set(); // 読込を待っているファイル id +let loadRunning = false; +let previewId = null; // データ確認タブに表示しているファイル id +let dataReady = false; // 読み込めたデータ元が1つ以上あり、読込中でもない(GUI 同期の描画に必要) let renderTimer = null; let loadTimer = null; let latestGeneration = 0; // 最後に要求された描画の世代番号 let renderRunning = false; let renderDirty = false; -let loadSeq = 0; let loadCount = 0; -let loadWarnings = []; +let batchLoaded = false; // この読込のまとまりで、実際に読み込めたファイルがある(data-load-count の数え方) let editCodeProvider = () => ""; // 編集モードのコード(Pythonコードタブの textarea の内容) let editHasRun = false; // 編集モードに入ってから「実行」したことがある(フォント取得後の描き直しの判断に使う) let readyCallbacks = []; @@ -55,8 +64,17 @@ let scriptRefreshAfterRender = false; let scriptRefreshFailed = false; let lastRenderStatus = null; // 直近の描画成功のステータス(コード更新のエラーから復帰したときに戻す) -const setDataState = (state) => { - dataReady = state === "ready"; +// data-data-state: 読込中が1つでもあれば loading、なければ(一覧に)失敗が1つでもあれば error、すべて成功なら ready、ファイルなしは none。 +// 描画できるのは、読込中でなく、読み込めたデータ元が1つ以上あるとき(一部が失敗していても描く)。 +const refreshDataState = () => { + const list = getSettings().load.files; + const states = list.map((f) => (sources.get(f.id) || { state: "loading" }).state); + const loading = pendingLoad.size > 0 || loadRunning || loadTimer !== null || states.includes("loading"); + let state = "ready"; + if (list.length === 0) state = "none"; + else if (loading) state = "loading"; + else if (states.includes("error")) state = "error"; + dataReady = !loading && states.includes("ready"); root.dataset.dataState = state; // none | loading | ready | error }; const setRenderState = (state) => { @@ -70,6 +88,7 @@ const setFontState = (state) => { }; export const isPythonReady = () => api !== null; +export const hasLoadedFiles = () => getSettings().load.files.length > 0; export const isDataReady = () => dataReady; export const setEditCodeProvider = (fn) => { editCodeProvider = fn; @@ -212,6 +231,11 @@ const waitForFonts = async () => { } }; +// 同期モードで新しいコードを作れなかったとき、表示中のコードが最新でないことを示す(表示中のコードがあるときだけ) +const markCodeStale = () => { + if (!isEditMode() && getGeneratedCode()) setCodeStale(true); +}; + // ---- 描画 ---- const executeRender = async (generation) => { const stale = () => generation !== latestGeneration || !canRender(); @@ -229,10 +253,10 @@ const executeRender = async (generation) => { if (!editing && typeof result.code === "string" && !isEditMode()) setGeneratedCode(result.code, generation); if (Number.isInteger(result.seriesCount)) { const skipped = Number.isInteger(result.skipRows) ? result.skipRows : 0; - const warnings = [...loadWarnings, ...warningTexts(result.warnings)]; + const warnings = [...currentLoadWarnings({ consumeNotes: true }), ...warningTexts(result.warnings)]; lastRenderStatus = { message: `描画に成功しました(${result.seriesCount}系列、スキップ${skipped}行)。`, detail: "", warnings }; } else { - lastRenderStatus = { message: "コードの実行に成功しました。", detail: result.output || "", warnings: loadWarnings }; + lastRenderStatus = { message: "コードの実行に成功しました。", detail: result.output || "", warnings: currentLoadWarnings({ consumeNotes: true }) }; } scriptRefreshFailed = false; setStatus(lastRenderStatus.message, "ok", lastRenderStatus.detail, lastRenderStatus.warnings); @@ -240,6 +264,7 @@ const executeRender = async (generation) => { const traceback = result.error && result.error.traceback ? result.error.traceback : ""; showCodeOutput({ output: result.output, traceback, kind: traceback ? "error" : "ok" }); reportError(result.error); + markCodeStale(); } } catch (err) { if (generation === latestGeneration) reportException(err, "render"); @@ -321,6 +346,7 @@ const refreshScript = () => { } else { scriptRefreshFailed = true; reportError(result.error); + markCodeStale(); } } catch (err) { reportException(err, "script"); @@ -332,21 +358,162 @@ const scheduleScriptRefresh = () => { scriptTimer = window.setTimeout(refreshScript, SCRIPT_REFRESH_DELAY_MS); }; -// ---- 読込 ---- -const doLoad = async () => { - if (!api || !selectedFile) return; - const seq = ++loadSeq; - const file = selectedFile; - setDataState("loading"); - if (renderRunning) { - // 実行中の描画は古いデータのものなので結果を捨てる - latestGeneration += 1; - root.dataset.renderGeneration = String(latestGeneration); +// ---- 読込(複数ファイル: D6)---- +const files = () => getSettings().load.files; +const fileLabel = (id) => { + const list = files(); + const i = list.findIndex((f) => f.id === id); + return i < 0 ? id : `データ${i + 1}(${list[i].name})`; +}; + +// 読込・描画の結果に添える警告: 直近のファイル操作の注意、各ファイルの読込警告(複数ファイルのときはデータ名つき)、読み込めなかったファイル +// consumeNotes: ファイル操作の注意(notes)は、その操作の結果を表示するときに1度だけ添える(表示したら消す) +const currentLoadWarnings = ({ consumeNotes = false } = {}) => { + const out = [...notes]; + if (consumeNotes) notes = []; + const list = files(); + const multi = list.length >= 2; + list.forEach((f, i) => { + const src = sources.get(f.id); + if (!src) return; + if (src.state === "ready") { + for (const w of warningTexts(src.result.warnings)) out.push(multi ? `データ${i + 1}(${f.name}): ${w}` : w); + } else if (src.state === "error") { + out.push(`データ${i + 1}(${f.name})を読み込めませんでした: ${src.error}`); + } + }); + return out; +}; + +const viewFiles = () => + files().map((f, i) => { + const src = sources.get(f.id); + const result = src ? src.result : null; + const ready = !!src && src.state === "ready"; + return { + id: f.id, + number: i + 1, + name: f.name, + state: src ? src.state : "loading", + kind: ready ? (result.encoding === null ? "excel" : "text") : null, + encoding: ready && result.encoding !== null && result.encoding !== undefined ? encodingDisplayName(result.encoding) : "", + sheets: ready && Array.isArray(result.sheets) ? result.sheets : [], + sheet: f.sheet, + error: src ? src.error : "", + pasted: !!src && src.pasted, + }; + }); + +// データ確認タブの表: 表示中のファイルの、保持しているプレビューを出す(Python は呼ばない) +const refreshPreview = () => { + const list = files(); + if (!list.some((f) => f.id === previewId)) previewId = list.length > 0 ? list[0].id : null; + const shown = sources.get(previewId); + if (shown && shown.result) showPreview(shown.result.preview, getSettings().plot.skipRows); +}; + +const onPreviewChange = (id) => { + previewId = id; + refreshPreview(); +}; + +// ファイル名・文字コード・シート・一覧・データ確認の切替・データ元セレクトを、現在のファイルの状態に合わせる +const updateFilesView = () => { + const list = files(); + const view = viewFiles(); + if (list.length === 0) { + showFileName(""); + showSheets([], null); + } else if (list.length === 1) { + const src = sources.get(list[0].id); + const v = view[0]; + showFileName(v.pasted ? `貼り付けたデータ(${list[0].name})` : list[0].name); + if (src && src.state === "ready") showEncoding(src.result.encoding === undefined ? null : src.result.encoding); + const stale = src && src.result; + showSheets(stale ? src.result.sheets : [], stale ? src.result.sheet : null); + } else { + showFileName(`${list[0].name} ほか ${list.length - 1} 件`); + showSheets([], null); } + showPastedSave(view.some((v) => v.pasted && v.state === "ready")); + renderFileList(view, { onSheet: (id, sheet) => setFileSheet(id, sheet), onRemove: (id) => removeFile(id) }); + renderPreviewSource(view, previewId, onPreviewChange); + refreshPreview(); + refreshSources(); +}; + +const reportLoadError = (id) => { + const src = sources.get(id); + if (!src || !src.errorInfo) return; + const error = src.errorInfo; + if (files().length >= 2) reportError({ ...error, message: `${fileLabel(id)}を読み込めませんでした: ${error.message}` }); + else reportError(error); +}; + +// 最後のファイルを取り除いたとき: 最初に開いたときの状態に戻す(編集モードのコードはそのまま) +const resetToInitialState = () => { + invalidatePendingRender(); + if (scriptTimer) window.clearTimeout(scriptTimer); + scriptTimer = null; + lastRenderStatus = null; + scriptRefreshFailed = false; + notes = []; + clearPlot(); + clearPreview(); + showCodeOutput({ output: "", kind: "ok" }); + if (!isEditMode()) setGeneratedCode("", null); + setStatus(""); +}; + +// 読込が一通り終わったとき(待っている読込が無いとき)の後処理: 列の選択肢・表示の更新、描画または失敗の報告 +const finalizeLoad = ({ counted = true } = {}) => { + const list = files(); + for (const f of list) { + const src = sources.get(f.id); + if (src && src.state === "ready") setSourceColumns(f.id, src.result.columns); + } + dropSourceColumns(list.map((f) => f.id)); + reconcileColumns(); + updateFilesView(); + refreshDataState(); + const loaded = batchLoaded; + batchLoaded = false; + if (list.length === 0) { + resetToInitialState(); + return; + } + if (dataReady) { + if (counted && loaded) { + loadCount += 1; + root.dataset.loadCount = String(loadCount); + } + if (isEditMode()) { + setStatus(EDIT_MODE_MESSAGE, "warning", "", currentLoadWarnings({ consumeNotes: true })); // 編集中は自動で実行しない(ファイルは作業フォルダに置かれている) + } else { + setStatus(""); + renderNow(); + } + return; + } + notes = []; + const failed = list.find((f) => sources.get(f.id) && sources.get(f.id).state === "error"); + if (failed) reportLoadError(failed.id); + markCodeStale(); +}; + +// 1つのファイルを読み込む(Python の呼び出しは同期。失敗はそのファイルの状態に残し、ほかのファイルには影響しない) +const loadOne = async (src) => { + const id = src.id; + src.state = "loading"; + const fail = (error) => { + src.state = "error"; + src.error = error.message; + src.errorInfo = error; + }; try { - const bytes = new Uint8Array(await file.arrayBuffer()); - if (seq !== loadSeq) return; - if (!excelReady && /\.xlsx$/i.test(file.name)) { + if (!src.bytes) src.bytes = new Uint8Array(await src.file.arrayBuffer()); + if (sources.get(id) !== src) return; + if (!excelReady && /\.xlsx$/i.test(src.file.name)) { excelInstalling = true; setStatus(EXCEL_LOADING_MESSAGE); refreshProgress(); @@ -357,66 +524,170 @@ const doLoad = async () => { excelInstalling = false; refreshProgress(); } - if (seq !== loadSeq) return; + if (sources.get(id) !== src) return; if (!installed.ok) { - setDataState("error"); - loadWarnings = []; - showEncoding(undefined); - reportError(installed.error); + fail(installed.error); return; } excelReady = true; setStatus(""); } const loadJson = JSON.stringify({ version: getSettings().version, load: getSettings().load }); - const result = callPython("loadFile", file.name, bytes, loadJson); - if (seq !== loadSeq) return; + const result = callPython("loadFile", src.file.name, src.bytes, loadJson, id, src.pasted); + if (sources.get(id) !== src) return; if (!result.ok) { - setDataState("error"); - loadWarnings = []; - showEncoding(undefined); - reportError(result.error); + fail(result.error); return; } - loadWarnings = warningTexts(result.warnings); - showEncoding(result.encoding === undefined ? null : result.encoding); - setColumns(result.columns); - showPreview(result.preview, getSettings().plot.skipRows); - setDataState("ready"); - loadCount += 1; - root.dataset.loadCount = String(loadCount); - if (isEditMode()) { - setStatus(EDIT_MODE_MESSAGE, "warning"); // 編集中は自動で実行しない(ファイルは作業フォルダに置かれている) - } else { - setStatus(""); - renderNow(); - } + src.state = "ready"; + batchLoaded = true; + src.result = result; + src.error = ""; + src.errorInfo = null; } catch (err) { - if (seq === loadSeq) { - setDataState("error"); + if (sources.get(id) === src) { + fail({ message: "内部エラーが発生しました。もう一度操作してください。" }); reportException(err, "loadFile"); } } }; -export const scheduleLoad = (delay = LOAD_DELAY_MS.file) => { - if (!selectedFile) return; - setDataState("loading"); // 読込が済むまで自動再描画は止める +const runLoads = async () => { + loadRunning = true; + if (renderRunning) { + // 実行中の描画は古いデータのものなので結果を捨てる + latestGeneration += 1; + root.dataset.renderGeneration = String(latestGeneration); + } + try { + while (pendingLoad.size > 0 && loadTimer === null) { + const id = pendingLoad.values().next().value; + pendingLoad.delete(id); + const src = sources.get(id); + if (!src || !files().some((f) => f.id === id)) continue; + await loadOne(src); + } + } finally { + loadRunning = false; + } + if (pendingLoad.size === 0 && loadTimer === null) finalizeLoad(); +}; + +// ids: "all"(すべてのファイル)または id の配列。delay の間にさらに予約されたら、まとめて1回読む +export const scheduleLoad = (delay = LOAD_DELAY_MS.file, ids = "all") => { + const list = files(); + if (list.length === 0) return; + for (const id of ids === "all" ? list.map((f) => f.id) : ids) { + const src = sources.get(id); + if (!src) continue; + pendingLoad.add(id); + src.state = "loading"; + } if (loadTimer) window.clearTimeout(loadTimer); - if (!api) return; // Python の起動後に handlePythonReady が読み込む - loadTimer = window.setTimeout(() => { - loadTimer = null; - doLoad(); - }, delay); + loadTimer = null; + if (api) { + loadTimer = window.setTimeout(() => { + loadTimer = null; + if (!loadRunning) runLoads(); + }, delay); + } + refreshDataState(); // 読込が済むまで自動再描画は止める +}; + +const nextFileId = (entries) => `d${Math.max(0, ...entries.map((e) => Number(e.id.slice(1)))) + 1}`; + +// ファイルを読み込む。mode: "replace"(既定。すべてのデータを置き換える)| "add"(追加する)。 +// 同じ名前のファイルは、その項目を置き換える(同じ id のまま読み直す)。上限は MAX_FILES 個。 +export const selectFiles = (fileList, options = {}) => { + const incoming = Array.from(fileList || []).filter(Boolean); + if (incoming.length === 0) return; + const mode = options.mode === "add" ? "add" : "replace"; + const pasted = options.pasted === true; + const nextNotes = Array.isArray(options.notes) ? [...options.notes] : []; + let entries = mode === "add" ? files().map((f) => ({ ...f })) : []; + const accepted = []; + let skipped = 0; + const staged = new Map(); + for (const file of incoming) { + const existing = entries.find((e) => e.name === file.name); + if (existing) { + existing.sheet = ""; + staged.set(existing.id, file); + accepted.push(existing.id); + } else if (entries.length >= MAX_FILES) { + skipped += 1; + } else { + const entry = { id: nextFileId(entries), name: file.name, sheet: "" }; + entries.push(entry); + staged.set(entry.id, file); + accepted.push(entry.id); + } + } + if (skipped > 0) { + if (mode === "replace") nextNotes.push(`読み込めるファイルは${MAX_FILES}個までです。先頭から${MAX_FILES}個を読み込みました。`); + else if (accepted.length > 0) nextNotes.push(`読み込めるファイルは${MAX_FILES}個までです。先頭から${accepted.length}個を追加しました。`); + else { + setStatus(`読み込めるファイルは${MAX_FILES}個までです。これ以上追加できません。`, "warning"); + return; + } + } + if (mode === "replace") { + // 古いデータを先に捨てる(Python 側も)。系列のデータ元は先頭のファイルに戻す + sources.clear(); + pendingLoad.clear(); + if (api) callPython("clearSources"); + for (const s of getSettings().series) if (s.source !== "") updateSeries(s.id, { source: "" }, "reconcile"); + previewId = null; + } + for (const [id, file] of staged) { + const old = sources.get(id); + sources.set(id, { id, file, bytes: null, pasted, state: "loading", result: old ? old.result : null, error: "", errorInfo: null }); + } + notes = nextNotes; + setPath("load.files", entries, "reconcile"); // 再読込は下で予約する + updateFilesView(); + scheduleLoad(LOAD_DELAY_MS.file, accepted); +}; + +// 1つのファイルを選ぶ(貼り付けたデータなど)。すべてのデータを置き換える +export const selectFile = (file, options = {}) => { + if (file) selectFiles([file], { ...options, mode: "replace" }); +}; + +// ファイルを取り除く。それを使っていた系列は、データ元を先頭に戻し X / Y を自動に戻す(警告を添える) +export const removeFile = (id) => { + const list = files(); + const index = list.findIndex((f) => f.id === id); + if (index < 0) return; + const removed = list[index]; + const msgs = []; + const { series } = getSettings(); + series.forEach((s, i) => { + if (!(s.source === id || (s.source === "" && index === 0))) return; // "" は先頭のファイルを指す + updateSeries(s.id, { source: "", x: "", y: "" }, "reconcile"); + if (i === 0 && getSettings().plot.xColumn) setPath("plot.xColumn", "", "reconcile"); + msgs.push(`データ${index + 1}(${removed.name})を削除したため、系列${i + 1}の列を自動に戻しました。`); + }); + sources.delete(id); + pendingLoad.delete(id); + if (api) callPython("removeSource", id); + notes = msgs; + setPath("load.files", list.filter((f) => f.id !== id), "reconcile"); + if (loadRunning || loadTimer !== null) { + updateFilesView(); + refreshDataState(); + return; // 読込が終わったときに、まとめて後処理する + } + finalizeLoad({ counted: false }); }; -// 同じファイルをもう一度選んだ場合も再読込する(入力欄は main.js が空に戻す) -export const selectFile = (file) => { - if (!file) return; - selectedFile = file; - loadWarnings = []; - showFileName(file.name); - scheduleLoad(LOAD_DELAY_MS.file); +// 貼り付けたデータのデータ元(貼り付けたデータの保存用) +export const getSelectedSource = () => { + for (const f of files()) { + const src = sources.get(f.id); + if (src && src.pasted) return { file: src.file, pasted: true }; + } + return { file: null, pasted: false }; }; // ---- Pythonコードタブのモード切替・実行 ---- @@ -487,7 +758,7 @@ export const saveNow = async () => { return; } downloadDataUri(result.filename, result.dataUri); - setStatus(`描画データを保存しました: ${result.filename}`, "ok", "", loadWarnings); + setStatus(`描画データを保存しました: ${result.filename}`, "ok", "", currentLoadWarnings()); } catch (err) { reportException(err, "save"); } @@ -542,7 +813,7 @@ const handlePythonReady = () => { readyCallbacks = []; for (const fn of callbacks) fn(); refreshProgress(); - scheduleLoad(LOAD_DELAY_MS.file); // 起動前に選ばれたファイルがあれば、最新の設定で読み込んで1回描画する + scheduleLoad(LOAD_DELAY_MS.file, "all"); // 起動前に選ばれたファイルがあれば、最新の設定で読み込んで1回描画する }; // 設定の変更を、読込・再描画・表示更新・無視のどれにするか振り分ける @@ -550,8 +821,10 @@ const routeChange = (_settings, change) => { if (change.origin !== "user") return; const path = change.kind === "path" ? change.path : "series"; if (path === "plot.skipRows") applySkipRows(getSettings().plot.skipRows); // 表の灰色表示は Python を呼ばず即時更新 - if (path.startsWith("load.")) { - scheduleLoad(path === "load.delimiter" ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header); + if (path === "load.files" && change.fileId) { + scheduleLoad(LOAD_DELAY_MS.file, [change.fileId]); + } else if (path.startsWith("load.")) { + scheduleLoad(SLOW_LOAD_PATHS.has(path) ? LOAD_DELAY_MS.delimiter : LOAD_DELAY_MS.header, "all"); } else if (path.startsWith("save.")) { if (!isEditMode()) scheduleScriptRefresh(); // 画像は変わらない。保存設定を反映したコードだけ作り直す } else { @@ -566,7 +839,7 @@ const routeChange = (_settings, change) => { export const initBridge = () => { root.dataset.appState = root.dataset.appState || "starting"; - setDataState("none"); + refreshDataState(); setRenderState("idle"); setFontState("idle"); refreshLatinState(); diff --git a/js/code-state.js b/js/code-state.js index f9d739e..8851f2e 100644 --- a/js/code-state.js +++ b/js/code-state.js @@ -4,7 +4,7 @@ // DOM には だけ反映する。 const root = typeof document !== "undefined" ? document.documentElement : null; const listeners = new Set(); -const state = { mode: "sync", generatedCode: "", generation: null }; +const state = { mode: "sync", generatedCode: "", generation: null, stale: false }; if (root) root.dataset.codeMode = state.mode; @@ -16,6 +16,7 @@ export const getCodeMode = () => state.mode; export const isEditMode = () => state.mode === "edit"; export const getGeneratedCode = () => state.generatedCode; export const getGeneratedGeneration = () => state.generation; +export const isCodeStale = () => state.stale; export const subscribeCode = (fn) => { listeners.add(fn); @@ -26,6 +27,7 @@ export const setCodeMode = (mode) => { if (mode !== "sync" && mode !== "edit") return; if (state.mode === mode) return; state.mode = mode; + state.stale = false; // 編集モードでは「最新でない」表示は使わない(同期に戻すと再描画される) if (root) root.dataset.codeMode = mode; notify({ kind: "mode" }); }; @@ -34,5 +36,13 @@ export const setCodeMode = (mode) => { export const setGeneratedCode = (code, generation) => { state.generatedCode = code; state.generation = generation; + state.stale = false; notify({ kind: "generated" }); }; + +// 同期モードで、描画またはコードの更新に失敗して、表示中のコードが最新の設定を反映していない +export const setCodeStale = (stale) => { + if (state.stale === stale) return; + state.stale = stale; + notify({ kind: "stale" }); +}; diff --git a/js/defaults.js b/js/defaults.js index a7a0755..6e83898 100644 --- a/js/defaults.js +++ b/js/defaults.js @@ -1,7 +1,7 @@ // 設定オブジェクトの既定値。純粋な ES module(DOM に触れない)。 // py/mplgui/settings.py の default_settings() と同一でなければならない(tests/unit/test_defaults_parity.py が比較する)。 -export const SCHEMA_VERSION = 2; +export const SCHEMA_VERSION = 3; // UDカラーセット(系列の既定色の候補。この順で未使用の色を割り当てる) export const PALETTE = [ @@ -28,11 +28,21 @@ export const DEFAULT_SERIES = { markerSize: null, label: "", secondaryAxis: false, + source: "", }; export const DEFAULT_SETTINGS = { version: SCHEMA_VERSION, - load: { delimiter: "", hasHeader: true }, + load: { + delimiter: "", + hasHeader: true, + skipLines: 0, + thousands: "", + decimal: ".", + comment: "", + parseDates: true, + files: [], + }, plot: { type: "line", skipRows: 0, @@ -47,7 +57,7 @@ export const DEFAULT_SETTINGS = { margins: { left: null, right: null, bottom: null, top: null }, }, axes: { - x: { label: "", scale: "linear", min: null, max: null }, + x: { label: "", scale: "linear", min: null, max: null, dateFormat: "" }, y: { label: "", scale: "linear", min: null, max: null }, y2: { label: "", scale: "linear", min: null, max: null }, }, diff --git a/js/main.js b/js/main.js index 6927914..c8a7d95 100644 --- a/js/main.js +++ b/js/main.js @@ -1,26 +1,34 @@ // エントリ。DOM 構築後(module は defer)に UI を設定オブジェクトへ結び付ける。 // 画面の入力 → state.js(設定オブジェクト)→ bridge.js が JSON で Python を呼ぶ → 結果を ui/* が描く import { subscribe } from "./state.js"; -import { initBridge, saveNow, selectFile } from "./bridge.js"; +import { initBridge, saveNow, selectFiles } from "./bridge.js"; import { bindColorPanelOutsideClick } from "./ui/colorPicker.js"; import { bindCodeTab } from "./ui/codeTab.js"; import { applyStateToForm, bindForms } from "./ui/forms.js"; import { bindSaveFormatEvents, syncSaveFormatOptions } from "./ui/saveFormat.js"; +import { bindDropPaste } from "./ui/dropPaste.js"; +import { bindLoadSection } from "./ui/loadSection.js"; import { bindSaveSection } from "./ui/saveSection.js"; +import { bindDateFormat } from "./ui/dateFormat.js"; import { bindClipboard } from "./ui/clipboard.js"; -import { bindSeriesEvents, renderSeriesList, setColumns, syncMarkerSizeDisplay, syncVisibility } from "./ui/series.js"; +import { bindSeriesEvents, refreshSources, renderSeriesList, syncMarkerSizeDisplay, syncVisibility } from "./ui/series.js"; import { bindStyleSection } from "./ui/styleSection.js"; import { initPanelSections } from "./ui/panelSections.js"; import { bindTabs } from "./ui/tabs.js"; -const fileInput = document.getElementById("fileInput"); -if (fileInput) { - fileInput.addEventListener("change", () => { - const file = fileInput.files && fileInput.files[0]; - selectFile(file); // File は bridge.js が保持する(再読込用) - fileInput.value = ""; // 空に戻すので、同じファイルをもう一度選んでも change が起きて再読込される +// 「ファイルを選択」は選んだファイルですべてを置き換え、「+ ファイルを追加」は追加する(どちらも複数選択できる) +const bindFileInput = (inputId, mode) => { + const input = document.getElementById(inputId); + if (!input) return; + input.addEventListener("change", () => { + selectFiles(Array.from(input.files || []), { mode }); // File は bridge.js が保持する(再読込用) + input.value = ""; // 空に戻すので、同じファイルをもう一度選んでも change が起きて再読込される }); -} +}; +bindFileInput("fileInput", "replace"); +bindFileInput("addFileInput", "add"); +const addFileBtn = document.getElementById("addFileBtn"); +if (addFileBtn) addFileBtn.addEventListener("click", () => document.getElementById("addFileInput").click()); const savePlotBtn = document.getElementById("savePlotBtn"); if (savePlotBtn) savePlotBtn.addEventListener("click", () => saveNow()); @@ -29,7 +37,10 @@ bindTabs(); bindForms(); bindSeriesEvents(); bindSaveFormatEvents(); +bindLoadSection(); +bindDropPaste(); bindSaveSection(); +bindDateFormat(); bindClipboard(); bindCodeTab(); bindColorPanelOutsideClick(); @@ -48,6 +59,6 @@ subscribe((_settings, change) => { }); applyStateToForm(); -setColumns([]); +refreshSources(); syncSaveFormatOptions(); initBridge(); diff --git a/js/state.js b/js/state.js index 8bca88b..c7ebfea 100644 --- a/js/state.js +++ b/js/state.js @@ -41,6 +41,14 @@ export const setPath = (path, value, origin = "user") => { notify({ kind: "path", path, origin }); }; +// 読み込むファイル(load.files)の sheet を替える。通知は load.files の変更(読み直しのきっかけ) +export const setFileSheet = (id, sheet, origin = "user") => { + const file = settings.load.files.find((f) => f.id === id); + if (!file || file.sheet === sheet) return; + file.sheet = sheet; + notify({ kind: "path", path: "load.files", origin, fileId: id }); +}; + const findSeries = (id) => settings.series.find((s) => s.id === id); export const updateSeries = (id, patch, origin = "user") => { diff --git a/js/ui/codeTab.js b/js/ui/codeTab.js index a724819..2e559a1 100644 --- a/js/ui/codeTab.js +++ b/js/ui/codeTab.js @@ -2,7 +2,7 @@ // sync: 読み取り専用。GUI から生成したスクリプトを表示(描画の成功ごとに更新)。 // edit: 編集可。GUI の変更では上書きしない。「実行」(Ctrl/Cmd+Enter)でそのコードを実行する。 import { enterEditMode, isDataReady, leaveEditMode, runCode, scriptFilename, setEditCodeProvider } from "../bridge.js"; -import { getCodeMode, getGeneratedCode, getGeneratedGeneration, isEditMode, subscribeCode } from "../code-state.js"; +import { getCodeMode, getGeneratedCode, getGeneratedGeneration, isCodeStale, isEditMode, subscribeCode } from "../code-state.js"; import { setStatus } from "./notify.js"; const $ = (id) => document.getElementById(id); @@ -31,6 +31,18 @@ const showGenerated = () => { updateGutter(); }; +// 「最新でない」表示: data-stale="true"(#customPyCode)と #codeStaleNote +const applyStale = () => { + const stale = isCodeStale() && !isEditMode(); + const editor = $("customPyCode"); + if (editor) { + if (stale) editor.dataset.stale = "true"; + else delete editor.dataset.stale; + } + const note = $("codeStaleNote"); + if (note) note.hidden = !stale; +}; + const applyMode = () => { const edit = isEditMode(); const editor = $("customPyCode"); @@ -122,6 +134,8 @@ export const bindCodeTab = () => { setEditCodeProvider(() => (editor ? editor.value : "")); subscribeCode((_state, change) => { + applyStale(); + if (change.kind === "stale") return; if (change.kind === "mode") { applyMode(); if (!isEditMode()) showGenerated(); // 「GUI から再生成」: 直近の生成コードをすぐ表示する @@ -152,5 +166,6 @@ export const bindCodeTab = () => { }); } applyMode(); + applyStale(); updateGutter(); }; diff --git a/js/ui/dataPreview.js b/js/ui/dataPreview.js index b401031..0461771 100644 --- a/js/ui/dataPreview.js +++ b/js/ui/dataPreview.js @@ -15,6 +15,18 @@ export const applySkipRows = (skipRows) => { rows.forEach((tr, idx) => tr.classList.toggle("skipped-row", idx < n)); }; +// ヘッダより前に読み飛ばした行(preamble: {lines, total})。表の上に、枠で囲んで等幅で出す +const preambleBox = (preamble) => { + if (!preamble || !(preamble.total > 0)) return null; + const box = el("div"); + box.id = "dataPreamble"; + box.appendChild(el("div", `ヘッダより前に読み飛ばした行(${preamble.total}行)`)).className = "preamble-title"; + box.appendChild(el("pre", preamble.lines.join("\n"))).className = "preamble-lines"; + const more = preamble.total - preamble.lines.length; + if (more > 0) box.appendChild(el("div", `…ほか ${more} 行`)).className = "preamble-more"; + return box; +}; + export const showPreview = (preview, skipRows = 0) => { const dataArea = document.getElementById("dataArea"); if (!dataArea) return; @@ -29,7 +41,15 @@ export const showPreview = (preview, skipRows = 0) => { const numHead = el("th", "#"); numHead.className = "row-number"; headRow.appendChild(numHead); - for (const name of preview.columns) headRow.appendChild(el("th", name)); + preview.columns.forEach((name, i) => { + const th = el("th", name); + if (preview.columnKinds && preview.columnKinds[i] === "datetime") { + const tag = el("span", "日時"); // 日時として読み込んだ列の印 + tag.className = "col-kind-tag"; + th.appendChild(tag); + } + headRow.appendChild(th); + }); table.appendChild(el("thead")).appendChild(headRow); const body = el("tbody"); preview.rows.forEach((row, idx) => { @@ -45,6 +65,12 @@ export const showPreview = (preview, skipRows = 0) => { const wrap = el("div"); wrap.className = "table-wrap"; wrap.appendChild(table); - dataArea.replaceChildren(meta, wrap); + dataArea.replaceChildren(...[preambleBox(preview.preamble), meta, wrap].filter(Boolean)); applySkipRows(skipRows); }; + +// 初期状態(表なし)に戻す +export const clearPreview = () => { + const dataArea = document.getElementById("dataArea"); + if (dataArea) dataArea.replaceChildren(); +}; diff --git a/js/ui/dateFormat.js b/js/ui/dateFormat.js new file mode 100644 index 0000000..b35f6fe --- /dev/null +++ b/js/ui/dateFormat.js @@ -0,0 +1,53 @@ +// 軸・目盛区分: X 軸の日時の表示書式(プリセット / 任意)。値は state の axes.x.dateFormat(文字列、"" = 自動)に書き込む。 +// 表示・非表示(#xDateFormatGroup)は、X に日時の列を使っているかで series.js の syncVisibility が決める。 +import { getSettings, setPath } from "../state.js"; + +const byId = (id) => document.getElementById(id); +const PRESETS = ["", "%Y-%m-%d", "%Y/%m/%d", "%m/%d", "%m月%d日", "%H:%M", "%m/%d %H:%M"]; +const PATH = "axes.x.dateFormat"; + +let customMode = false; + +const paint = () => { + const select = byId("xDateFormat"); + const custom = byId("xDateFormatCustom"); + const row = byId("xDateFormatRow"); + if (select) select.value = customMode ? "custom" : getSettings().axes.x.dateFormat || ""; + if (custom) custom.hidden = !customMode; + if (row) row.classList.toggle("has-custom", customMode); +}; + +// state → 画面(プリセットに無い書式は「任意」で表示する) +export const syncDateFormatView = () => { + const value = getSettings().axes.x.dateFormat || ""; + customMode = !PRESETS.includes(value); + const custom = byId("xDateFormatCustom"); + if (custom && customMode) custom.value = value; + paint(); +}; + +export const bindDateFormat = () => { + const select = byId("xDateFormat"); + const custom = byId("xDateFormatCustom"); + if (select) { + select.addEventListener("change", () => { + if (select.value === "custom") { + customMode = true; + if (custom) custom.value = getSettings().axes.x.dateFormat || ""; + paint(); + if (custom) custom.focus(); + } else { + customMode = false; + setPath(PATH, select.value); + paint(); + } + }); + } + if (custom) { + // input だけを購読する(プリセットに切り替えた値を、古い入力で上書きしないため) + custom.addEventListener("input", () => { + if (customMode) setPath(PATH, custom.value); + }); + } + syncDateFormatView(); +}; diff --git a/js/ui/dropPaste.js b/js/ui/dropPaste.js new file mode 100644 index 0000000..6d0b6bb --- /dev/null +++ b/js/ui/dropPaste.js @@ -0,0 +1,179 @@ +// ファイルのドラッグ&ドロップ(ページ全体)と、表の貼り付け(#pasteArea とページ上の paste)。 +// どちらも bridge.js の selectFile に渡すだけ(ファイルを選んだのと同じ経路で読み込む)。 +// 貼り付けたデータは UTF-8 の pasted_data.tsv というファイルにして読み込む。 +import { getSelectedSource, hasLoadedFiles, selectFile, selectFiles } from "../bridge.js"; +import { setStatus } from "./notify.js"; + +export const PASTED_FILENAME = "pasted_data.tsv"; +export const NOT_A_TABLE_MESSAGE = "貼り付けた内容が表ではありません。Excel などで表の範囲をコピーしてから貼り付けてください。"; +export const PASTE_WHILE_LOADED_MESSAGE = + "表を貼り付けて読み込むときは、「表を貼り付け」欄に貼り付けてください(読み込み済みのデータは置き換わります)。"; +const NO_FILE_MESSAGE = "ドロップされたものから読み込めるファイルが見つかりませんでした(フォルダは読み込めません)。"; + +const hasFiles = (event) => { + const types = event.dataTransfer && event.dataTransfer.types; + return !!types && Array.from(types).includes("Files"); +}; + +// ---- ドロップ ---- +// データが読込済みのときは「置き換えて読み込む」「追加して読み込む」の2区画(#dropReplace / #dropAdd)を出し、 +// ポインタの下の区画で決める(区画の外は置き換え)。未読込のときは #dropLoad だけ。 +const overlay = () => document.getElementById("dropOverlay"); +let dragDepth = 0; + +const zone = (id) => document.getElementById(id); + +const showOverlay = () => { + const el = overlay(); + if (!el) return; + const loaded = hasLoadedFiles(); + if (zone("dropLoad")) zone("dropLoad").hidden = loaded; + if (zone("dropReplace")) zone("dropReplace").hidden = !loaded; + if (zone("dropAdd")) zone("dropAdd").hidden = !loaded; + el.hidden = false; + el.setAttribute("aria-hidden", "false"); +}; +const highlightZone = (target) => { + const under = target instanceof Element ? target.closest(".drop-zone") : null; + for (const id of ["dropLoad", "dropReplace", "dropAdd"]) { + const z = zone(id); + if (z) z.classList.toggle("drop-zone-active", z === under); + } +}; +const hideOverlay = () => { + dragDepth = 0; + const el = overlay(); + if (!el) return; + highlightZone(null); + el.hidden = true; + el.setAttribute("aria-hidden", "true"); +}; + +// ドロップされた項目からファイルだけを取り出す(フォルダは除く。drop イベントの中で同期的に呼ぶ) +const droppedFiles = (dataTransfer) => { + const files = []; + const items = dataTransfer.items ? Array.from(dataTransfer.items) : []; + if (items.length > 0) { + for (const item of items) { + if (item.kind !== "file") continue; + const entry = typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null; + if (entry && entry.isDirectory) continue; + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; + } + return Array.from(dataTransfer.files || []); +}; + +const handleDrop = (event) => { + const files = droppedFiles(event.dataTransfer); + if (files.length === 0) { + setStatus(NO_FILE_MESSAGE, "warning"); + return; + } + const under = event.target instanceof Element ? event.target.closest(".drop-zone") : null; + selectFiles(files, { mode: under && under.id === "dropAdd" ? "add" : "replace" }); +}; + +const bindDrop = () => { + window.addEventListener("dragenter", (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + dragDepth += 1; + showOverlay(); + }); + window.addEventListener("dragover", (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); // ブラウザがファイルを開かないように + event.dataTransfer.dropEffect = "copy"; + showOverlay(); + highlightZone(event.target); + }); + window.addEventListener("dragleave", (event) => { + // オーバーレイを出している間は、types が空になるブラウザ(Safari)でも数える(出しっぱなしにしない) + const el = overlay(); + if (!hasFiles(event) && !(el && !el.hidden)) return; + dragDepth -= 1; + if (dragDepth <= 0) hideOverlay(); + }); + window.addEventListener("drop", (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + hideOverlay(); + handleDrop(event); + }); + window.addEventListener("dragend", hideOverlay); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") hideOverlay(); + }); +}; + +// ---- 貼り付け ---- +const isTable = (text) => text.includes("\t") || text.split("\n").filter((line) => line.trim() !== "").length >= 2; + +const loadPastedText = (rawText) => { + const text = String(rawText || "").replace(/\r\n?/g, "\n"); + if (!isTable(text)) { + setStatus(NOT_A_TABLE_MESSAGE, "warning"); + return; + } + selectFile(new File([text], PASTED_FILENAME, { type: "text/tab-separated-values" }), { pasted: true }); +}; + +const isEditable = (el) => !!el && el instanceof Element && el.closest("input, textarea, select, [contenteditable]") !== null; + +const bindPaste = () => { + const area = document.getElementById("pasteArea"); + if (area) { + area.addEventListener("paste", (event) => { + event.preventDefault(); + const text = event.clipboardData ? event.clipboardData.getData("text/plain") : ""; + area.value = ""; + loadPastedText(text); + }); + // メニューの「貼り付け」などで、paste を経ずに値が入った場合 + area.addEventListener("input", () => { + const text = area.value; + area.value = ""; + if (text !== "") loadPastedText(text); + }); + } + document.addEventListener("paste", (event) => { + if (isEditable(event.target) || isEditable(document.activeElement)) return; // 入力欄への貼り付けは通常どおり + const text = event.clipboardData ? event.clipboardData.getData("text/plain") : ""; + if (text.trim() === "") return; + event.preventDefault(); + if (hasLoadedFiles()) { + // 読み込み済みのデータを、うっかり貼り付けで置き換えない(「表を貼り付け」欄なら置き換える) + setStatus(PASTE_WHILE_LOADED_MESSAGE, "warning"); + return; + } + loadPastedText(text); + }); +}; + +// ---- 貼り付けたデータの保存 ---- +const bindSavePasted = () => { + const button = document.getElementById("savePastedBtn"); + if (!button) return; + button.addEventListener("click", async () => { + const { file, pasted } = getSelectedSource(); // 貼り付けたデータのデータ元(あれば) + if (!file || !pasted) return; + const blob = new Blob([await file.arrayBuffer()], { type: "text/tab-separated-values" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = PASTED_FILENAME; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }); +}; + +export const bindDropPaste = () => { + bindDrop(); + bindPaste(); + bindSavePasted(); +}; diff --git a/js/ui/fileInfo.js b/js/ui/fileInfo.js index 268fa59..b0f082c 100644 --- a/js/ui/fileInfo.js +++ b/js/ui/fileInfo.js @@ -35,3 +35,9 @@ export const showEncoding = (encoding) => { label.textContent = encoding === null ? `形式: ${name}` : `文字コード: ${name}`; label.dataset.encoding = encoding === null ? "excel" : String(encoding); }; + +// 「貼り付けたデータを保存(.tsv)」(#savePastedBtn)は、現在のデータ元が貼り付けたデータのときだけ表示する +export const showPastedSave = (visible) => { + const button = document.getElementById("savePastedBtn"); + if (button) button.hidden = !visible; +}; diff --git a/js/ui/fileList.js b/js/ui/fileList.js new file mode 100644 index 0000000..de3c228 --- /dev/null +++ b/js/ui/fileList.js @@ -0,0 +1,73 @@ +// 読み込んだファイルの一覧(#fileList。2ファイル以上のときだけ表示)と、データ確認タブの表示切替(#previewSource)。 +// 一覧は bridge.js が作る view model から描くだけ。操作(シート変更・削除)は handlers 経由で bridge.js に返す。 +// file: {id, number, name, state: loading|ready|error, kind: "text"|"excel"|null, encoding, sheets, sheet, error, pasted} + +const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +const kindText = (file) => { + if (file.kind === "excel") return "Excel"; + return file.encoding ? `文字コード: ${file.encoding}` : ""; +}; + +const buildRow = (file, handlers) => { + const li = el("li", "file-row"); + li.id = `file-${file.id}`; + li.dataset.state = file.state; + li.appendChild(el("span", "file-row-no", `データ${file.number}`)); + const name = el("span", "file-row-name", file.pasted ? `貼り付けたデータ(${file.name})` : file.name); + name.title = file.name; + li.appendChild(name); + if (file.state === "loading") li.appendChild(el("span", "file-row-loading", "読み込み中…")); + else if (file.state === "ready") li.appendChild(el("span", "file-row-kind", kindText(file))); + if (file.state === "ready" && file.sheets.length > 0) { + const select = el("select", "file-row-sheet"); + select.id = `file-${file.id}-sheet`; + select.setAttribute("aria-label", `データ${file.number}のシート`); + for (const sheetName of file.sheets) { + const option = el("option", "", sheetName); + option.value = sheetName; + select.appendChild(option); + } + select.value = file.sheets.includes(file.sheet) ? file.sheet : file.sheets[0]; + select.addEventListener("change", () => handlers.onSheet(file.id, select.value)); + li.appendChild(select); + } + const remove = el("button", "small-btn ghost-btn", "削除"); + remove.type = "button"; + remove.id = `file-${file.id}-remove`; + remove.setAttribute("aria-label", `データ${file.number}(${file.name})を削除`); + remove.addEventListener("click", () => handlers.onRemove(file.id)); + li.appendChild(remove); + if (file.state === "error" && file.error) li.appendChild(el("div", "file-row-error", file.error)); + return li; +}; + +// files が 2 件以上のときだけ一覧を出す(1 件のときは従来の表示) +export const renderFileList = (files, handlers) => { + const list = document.getElementById("fileList"); + if (!list) return; + list.hidden = files.length < 2; + list.replaceChildren(...(files.length < 2 ? [] : files.map((f) => buildRow(f, handlers)))); +}; + +// データ確認タブの「表示するデータ」。files が 2 件以上のときだけ表示する +export const renderPreviewSource = (files, selectedId, onChange) => { + const group = document.getElementById("previewSourceGroup"); + const select = document.getElementById("previewSource"); + if (!group || !select) return; + group.hidden = files.length < 2; + select.replaceChildren( + ...files.map((f) => { + const option = el("option", "", `データ${f.number}: ${f.name}`); + option.value = f.id; + return option; + }), + ); + if (files.length > 0) select.value = files.some((f) => f.id === selectedId) ? selectedId : files[0].id; + select.onchange = () => onChange(select.value); +}; diff --git a/js/ui/forms.js b/js/ui/forms.js index 3a574d1..318e4d2 100644 --- a/js/ui/forms.js +++ b/js/ui/forms.js @@ -5,6 +5,11 @@ import { getPath, setPath } from "../state.js"; export const FIELDS = [ ["delimiter", "load.delimiter", "text"], ["hasHeader", "load.hasHeader", "checkbox"], + ["skipLines", "load.skipLines", "number"], + ["thousands", "load.thousands", "select"], + ["decimal", "load.decimal", "select"], + ["commentChar", "load.comment", "text"], + ["parseDates", "load.parseDates", "checkbox"], ["plotType", "plot.type", "select"], ["skipRows", "plot.skipRows", "number"], ["xColumn", "plot.xColumn", "select"], diff --git a/js/ui/loadSection.js b/js/ui/loadSection.js new file mode 100644 index 0000000..042f8d1 --- /dev/null +++ b/js/ui/loadSection.js @@ -0,0 +1,31 @@ +// データ読み込み区分: シートの選択(#sheetGroup / #sheetSelect。.xlsx のときだけ表示)。 +// 選んだシートは設定の load.files に書く(読み直しは bridge.js が load.files の変更を購読して行う)。 +import { getSettings, setFileSheet } from "../state.js"; + +// sheets: シート名の配列(xlsx 以外は空)、sheet: 読んだシート名 +export const showSheets = (sheets, sheet) => { + const group = document.getElementById("sheetGroup"); + const select = document.getElementById("sheetSelect"); + if (!group || !select) return; + const names = Array.isArray(sheets) ? sheets : []; + group.hidden = names.length === 0; + select.replaceChildren( + ...names.map((name) => { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + return option; + }), + ); + if (names.length > 0) select.value = names.includes(sheet) ? sheet : names[0]; +}; + +export const bindLoadSection = () => { + const select = document.getElementById("sheetSelect"); + if (select) { + select.addEventListener("change", () => { + const first = getSettings().load.files[0]; // このセレクトは、ファイルが1つのときだけ見える + if (first) setFileSheet(first.id, select.value); + }); + } +}; diff --git a/js/ui/plotView.js b/js/ui/plotView.js index 6fbb4d6..d7e86bb 100644 --- a/js/ui/plotView.js +++ b/js/ui/plotView.js @@ -9,3 +9,9 @@ export const showPlot = (dataUri, generation, summary) => { if (summary !== undefined) img.dataset.summary = JSON.stringify(summary); plotArea.replaceChildren(img); }; + +// 初期状態(画像なし)に戻す +export const clearPlot = () => { + const plotArea = document.getElementById("plotArea"); + if (plotArea) plotArea.replaceChildren(); +}; diff --git a/js/ui/series.js b/js/ui/series.js index d8220c2..28de326 100644 --- a/js/ui/series.js +++ b/js/ui/series.js @@ -3,7 +3,22 @@ import { addSeries, getSettings, hasSecondaryAxis, removeSeries, updateSeries, s import { bindSeriesColorControls } from "./colorPicker.js"; import { readNumber } from "./forms.js"; -let columns = []; // [{value: "__idx__0", label: "時間 [0]"}](読込のたびに更新) +// データ元(ファイル id)ごとの列の選択肢: [{value: "__idx__0", label: "時間 [0]", kind: "number"}](読込のたびに更新。kind: datetime / number / text) +const columnsById = new Map(); + +const loadedFiles = () => getSettings().load.files; +const sourceIdOf = (series) => series.source || (loadedFiles()[0] ? loadedFiles()[0].id : "d1"); // "" は先頭のファイル +const columnsOf = (series) => columnsById.get(sourceIdOf(series)) || []; +const hasManySources = () => loadedFiles().length >= 2; + +const isDatetimeColumn = (columns, value) => !!value && columns.some((c) => c.value === value && c.kind === "datetime"); + +// X に日時の列を使っているか(line / scatter はどれかの系列の X(その系列のデータ元の列)、bar は X列(系列1のデータ元)) +export const usesDatetimeX = () => { + const { plot, series } = getSettings(); + if (plot.type === "bar") return series.length > 0 && isDatetimeColumn(columnsOf(series[0]), plot.xColumn); + return series.some((s) => isDatetimeColumn(columnsOf(s), s.x)); +}; const esc = (text) => String(text).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); @@ -22,8 +37,9 @@ const fillSelect = (selectEl, options, selected) => { selectEl.value = options.some((o) => o.value === selected) ? selected : ""; }; -const xOptions = () => [{ value: "", label: "(index)" }, ...columns]; -const yOptions = () => [{ value: "", label: "(自動)" }, ...columns]; +const xOptions = (columns) => [{ value: "", label: "(index)" }, ...columns]; +const yOptions = (columns) => [{ value: "", label: "(自動)" }, ...columns]; +const sourceOptions = () => loadedFiles().map((f, i) => ({ value: f.id, label: `データ${i + 1}: ${f.name}` })); const cardHtml = (series, plotType) => { const id = (name) => `series-${series.id}-${name}`; @@ -32,6 +48,10 @@ const cardHtml = (series, plotType) => {
系列
+
+ + +
@@ -96,8 +116,11 @@ const buildCard = (series, plotType) => { item.className = "series-item"; item.dataset.seriesId = series.id; item.innerHTML = cardHtml(series, plotType); - fillSelect(item.querySelector(".series-x"), xOptions(), series.x); - fillSelect(item.querySelector(".series-y"), yOptions(), series.y); + const columns = columnsOf(series); + fillSelect(item.querySelector(".series-x"), xOptions(columns), series.x); + fillSelect(item.querySelector(".series-y"), yOptions(columns), series.y); + fillSelect(item.querySelector(".series-source"), sourceOptions(), sourceIdOf(series)); + item.querySelector(".series-source-wrap").hidden = !hasManySources(); item.querySelector(".series-line-style").value = series.lineStyle; bindSeriesColorControls(item, (color) => updateSeries(series.id, { color })); item.querySelector(".remove-series").addEventListener("click", () => removeSeries(series.id)); @@ -112,6 +135,9 @@ export const syncVisibility = () => { for (const group of document.querySelectorAll("#seriesList .series-x-wrap")) { group.style.display = usePerSeriesX ? "block" : "none"; } + for (const wrap of document.querySelectorAll("#seriesList .series-source-wrap")) wrap.hidden = !hasManySources(); + const dateGroup = document.getElementById("xDateFormatGroup"); + if (dateGroup) dateGroup.hidden = !usesDatetimeX(); const hasY2 = hasSecondaryAxis(); for (const id of ["y2LabelGroup", "y2AxisSettingsGroup"]) { const el = document.getElementById(id); @@ -131,9 +157,40 @@ export const syncMarkerSizeDisplay = () => { } }; +// カードを描いたときの、データ元と列の選択肢の状態(読込が終わったとき、変わっていなければ描き直さない) +let renderedStructure = ""; +const structureOf = () => { + const { series, plot } = getSettings(); + return JSON.stringify([ + loadedFiles().map((f) => [f.id, f.name]), + series.map((s) => [s.id, sourceIdOf(s), columnsOf(s)]), + plot.type, + plot.xColumn, + ]); +}; + +// 表示中のカードの選択が、設定と同じか(違えば描き直す) +const cardsMatchSettings = () => { + const list = document.getElementById("seriesList"); + if (!list) return false; + const { series } = getSettings(); + const items = list.querySelectorAll(".series-item"); + if (items.length !== series.length) return false; + return series.every((s, i) => { + const item = items[i]; + return ( + item.dataset.seriesId === s.id && + item.querySelector(".series-x")?.value === s.x && + item.querySelector(".series-y")?.value === s.y && + item.querySelector(".series-source")?.value === sourceIdOf(s) + ); + }); +}; + export const renderSeriesList = () => { const list = document.getElementById("seriesList"); if (!list) return; + renderedStructure = structureOf(); const { series, plot } = getSettings(); list.replaceChildren(...series.map((s) => buildCard(s, plot.type))); const items = list.querySelectorAll(".series-item"); @@ -151,23 +208,46 @@ export const renderSeriesList = () => { const refreshXColumnSelect = () => { const select = document.getElementById("xColumn"); - if (select) fillSelect(select, xOptions(), getSettings().plot.xColumn); + const { series, plot } = getSettings(); + if (select) fillSelect(select, xOptions(series[0] ? columnsOf(series[0]) : []), plot.xColumn); // 棒グラフの X は系列1のデータ元の列 }; -// 読込後の列の選択肢を更新する。もう存在しない列の選択は解除する(再描画は呼び出し側が行う)。 -export const setColumns = (newColumns) => { - columns = newColumns; - const valid = new Set(columns.map((c) => c.value)); +// ファイルの追加・削除などで、データ元の選択肢と各系列の列の選択肢を描き直す +export const refreshSources = () => { + refreshXColumnSelect(); + // データ元と列の選択肢が変わっていなければ、カードは描き直さない(入力中の欄や開いている選択肢を保つ) + if (renderedStructure === structureOf() && cardsMatchSettings()) return; + renderSeriesList(); +}; + +// 1つのデータ元の列の選択肢を更新する(読込のたびに呼ぶ)。 +export const setSourceColumns = (id, newColumns) => { + columnsById.set(id, newColumns); +}; + +export const dropSourceColumns = (keepIds) => { + const keep = new Set(keepIds); + for (const id of Array.from(columnsById.keys())) if (!keep.has(id)) columnsById.delete(id); +}; + +// 読込後の整合: もう存在しない列の選択は解除する(再描画は呼び出し側が行う)。列の選択肢が分かっているデータ元の系列だけを見る。 +export const reconcileColumns = () => { const settings = getSettings(); - if (settings.plot.xColumn && !valid.has(settings.plot.xColumn)) setPath("plot.xColumn", "", "reconcile"); + const first = settings.series[0]; + const firstColumns = first ? columnsById.get(sourceIdOf(first)) : null; + if (settings.plot.xColumn && firstColumns && !firstColumns.some((c) => c.value === settings.plot.xColumn)) { + setPath("plot.xColumn", "", "reconcile"); + } for (const s of settings.series) { + const known = columnsById.get(sourceIdOf(s)); + if (!known) continue; + const valid = new Set(known.map((c) => c.value)); const patch = {}; if (s.x && !valid.has(s.x)) patch.x = ""; if (s.y && !valid.has(s.y)) patch.y = ""; if (Object.keys(patch).length) updateSeries(s.id, patch, "reconcile"); } - refreshXColumnSelect(); - renderSeriesList(); + refreshSources(); }; const FIELD_BY_CLASS = [ @@ -180,11 +260,25 @@ const FIELD_BY_CLASS = [ ["series-use-y2", "secondaryAxis", "checkbox"], ]; +// データ元を替えたら、X / Y は自動に戻す(別のファイルの列番号は意味が変わる)。系列1なら棒グラフの X列も戻す +const onSourceChange = (seriesId, source) => { + const { series, plot } = getSettings(); + const current = series.find((s) => s.id === seriesId); + if (!current || sourceIdOf(current) === source) return; + if (series[0] && series[0].id === seriesId && plot.xColumn) setPath("plot.xColumn", ""); + updateSeries(seriesId, { source, x: "", y: "" }); + refreshSources(); +}; + const onSeriesInput = (event) => { const target = event.target; if (!target || !target.matches || !target.matches("input, select")) return; const item = target.closest(".series-item"); if (!item) return; + if (target.classList.contains("series-source")) { + onSourceChange(item.dataset.seriesId, target.value); + return; + } const entry = FIELD_BY_CLASS.find(([cls]) => target.classList.contains(cls)); if (!entry) return; const [, field, kind] = entry; diff --git a/py/main.py b/py/main.py index 82a66c6..1eaf3a0 100644 --- a/py/main.py +++ b/py/main.py @@ -72,8 +72,20 @@ async def _ensure_excel(): return json.dumps({"ok": False, "error": err.to_dict()}, ensure_ascii=False) -def _load_file(name, data, load_settings_json): - return api.load_file_json(str(name), data.to_py(), str(load_settings_json)) +def _load_file(name, data, load_settings_json, source_id=None, pasted=None): + # source_id / pasted は JS が渡さない(null / undefined)ときは既定値 + return api.load_file_json( + str(name), data.to_py(), str(load_settings_json), + source_id if isinstance(source_id, str) else "d1", pasted is True, + ) + + +def _remove_source(source_id): + return api.remove_source_json(str(source_id)) + + +def _clear_sources(): + return api.clear_sources_json() def _code(value): @@ -110,6 +122,8 @@ def _script_filename(save_filename): _PROXIES = { "ensureExcel": create_proxy(_ensure_excel), "loadFile": create_proxy(_load_file), + "removeSource": create_proxy(_remove_source), + "clearSources": create_proxy(_clear_sources), "render": create_proxy(_render), "save": create_proxy(_save), "copyImage": create_proxy(_copy_image), diff --git a/py/mplgui/api.py b/py/mplgui/api.py index d047712..91c62c0 100644 --- a/py/mplgui/api.py +++ b/py/mplgui/api.py @@ -6,12 +6,14 @@ from __future__ import annotations import base64 +import dataclasses +import hashlib import json import struct import traceback from pathlib import Path -from . import fonts +from . import fonts, loader from .codegen import GENERIC_ERROR_MESSAGE, GeneratedScript, generate_script from .dataprep import plan_plot from .errors import UserError @@ -28,43 +30,101 @@ INTERNAL_ERROR_MESSAGE = "内部エラーが発生しました。もう一度操作してください。" +@dataclasses.dataclass +class LoadedSource: + """読み込み済みの1つのファイル(データN)。""" + + id: str + name: str + df: object + info: SourceInfo + written: Path | None = None # 作業フォルダに書き出したファイル + + class Session: - """読み込み済みデータ(ブラウザのタブごとに1つ)。""" + """読み込み済みデータ(ブラウザのタブごとに1つ)。ファイル id("d1", "d2", ...)ごとに DataFrame を持つ。""" def __init__(self): - self.df = None - self.filename = None - self.source: SourceInfo | None = None + self.sources: dict[str, LoadedSource] = {} self.workdir: Path | None = None # 設定すると、アップロードしたファイルを元の名前でここに置く - self._written: Path | None = None - - def forget(self) -> None: - self.df = None - self.filename = None - self.source = None - self._remove_written() - - def _remove_written(self) -> None: - if self._written is not None: + self.excels: dict[str, tuple[str, object]] = {} # id → (バイト列のハッシュ, 開いた pd.ExcelFile)。シートの切り替えを速くする + + # ---- 先頭のデータ元(1ファイルのときの従来の窓口)---- + def _first(self) -> LoadedSource | None: + return next(iter(self.sources.values()), None) + + @property + def df(self): + first = self._first() + return first.df if first else None + + @property + def filename(self): + first = self._first() + return first.name if first else None + + @property + def source(self) -> SourceInfo | None: + first = self._first() + return first.info if first else None + + @property + def excel(self): + entry = self.excels.get("d1") or next(iter(self.excels.values()), None) + return entry[1] if entry else None + + # ---- データ元の操作 ---- + def forget(self, keep_excel: bool = False) -> None: + """すべてのデータ元と、作業フォルダに書き出したファイルを破棄する。""" + for sid in list(self.sources): + self.forget_source(sid, keep_excel=True) + if not keep_excel: + for sid in list(self.excels): + self.drop_excel(sid) + + def forget_source(self, sid: str, keep_excel: bool = False) -> None: + src = self.sources.pop(sid, None) + if src is not None: + self._remove_written(src) + if not keep_excel: + self.drop_excel(sid) + + def drop_excel(self, sid: str) -> None: + entry = self.excels.pop(sid, None) + if entry is not None: try: - self._written.unlink() - except OSError: + entry[1].close() + except Exception: # noqa: BLE001 - 閉じられなくても続行する pass - self._written = None - def write_upload(self, name: str, data: bytes) -> None: - """編集モードのスクリプトが読めるように、アップロードしたファイルを workdir に置く(前のファイルは消す)。""" + def excel_for(self, sid: str, raw: bytes): + """raw(xlsx のバイト列)の ExcelFile。同じバイト列なら開き直さない。""" + key = hashlib.sha256(raw).hexdigest() + entry = self.excels.get(sid) + if entry is None or entry[0] != key: + self.drop_excel(sid) + self.excels[sid] = (key, loader.open_excel(raw)) + return self.excels[sid][1] + + def _remove_written(self, src: LoadedSource) -> None: + path, src.written = src.written, None + if path is None or any(o.written == path for o in self.sources.values() if o is not src): + return # 同じ名前のファイルを別のデータ元も使っているときは消さない + try: + path.unlink() + except OSError: + pass + + def write_upload(self, src: LoadedSource, data: bytes) -> None: + """編集モードのスクリプトが読めるように、アップロードしたファイルを workdir に置く。""" if self.workdir is None: return - base = Path(name).name + base = Path(src.name).name if base in {"", ".", ".."}: - self._remove_written() return target = self.workdir / base - if self._written is not None and self._written != target: - self._remove_written() target.write_bytes(data) - self._written = target + src.written = target SESSION = Session() @@ -109,32 +169,54 @@ def _parse_json(text: str) -> dict: @_guard("load_file") -def load_file_json(name: str, data: bytes, load_settings_json: str) -> dict: - """ファイルを読み込み、列の選択肢とプレビューを返す。失敗時は読込済みデータを破棄する。""" - SESSION.forget() +def load_file_json(name: str, data: bytes, load_settings_json: str, source_id: str = "d1", pasted: bool = False) -> dict: + """ファイルを読み込み、列の選択肢とプレビューを返す。同じ source_id のデータ元だけを置き換える。 + 失敗したときは、その source_id のデータ元だけを破棄する(ほかのデータ元は残る)。 + + xlsx のシートは、読み込み設定の load.files のうち source_id の項目から決める(無ければ先頭のシート)。""" + SESSION.forget_source(source_id, keep_excel=True) load = parse_load_settings(_parse_json(load_settings_json)) raw = bytes(data) - loaded = load_file(raw, name, load.delimiter, load.has_header) - SESSION.df = loaded.df - SESSION.filename = name - SESSION.source = loaded.source - SESSION.write_upload(name, raw) + sheet = next((f.sheet for f in load.files if f.id == source_id), "") + excel = None + if Path(name).suffix.lower() == ".xlsx": + excel = SESSION.excel_for(source_id, raw) + else: + SESSION.drop_excel(source_id) + loaded = load_file( + raw, name, load.delimiter, load.has_header, + skip_lines=load.skip_lines, thousands=load.thousands, decimal=load.decimal, comment=load.comment, + sheet=sheet, excel=excel, parse_dates=load.parse_dates, + ) + info = loaded.source + if pasted: + info = dataclasses.replace(info, pasted=True) + src = LoadedSource(id=source_id, name=name, df=loaded.df, info=info) + SESSION.sources[source_id] = src + SESSION.write_upload(src, raw) return { "ok": True, "encoding": loaded.encoding, "columns": column_options(loaded.df), - "preview": build_preview(loaded.df, PREVIEW_ROWS), - "warnings": [], + "preview": build_preview(loaded.df, PREVIEW_ROWS, preamble=(loaded.preamble_lines, loaded.preamble_total)), + "sheets": list(info.sheets) if info.kind == "xlsx" else [], + "sheet": info.sheet_name if info.kind == "xlsx" else None, + "warnings": list(loaded.warnings), } -def _source_info() -> SourceInfo: - if SESSION.source is not None: - return SESSION.source - name = Path(SESSION.filename or "data.csv").name - kind = Path(name).suffix.lower().lstrip(".") or "csv" - return SourceInfo(filename=name, kind=kind, encoding="utf-8", separator=",", has_header=True, - sheet_name="Sheet1" if kind == "xlsx" else None) +@_guard("remove_source") +def remove_source_json(source_id: str) -> dict: + """データ元を1つ取り除く(作業フォルダに書き出したファイルも消す)。無い id でもエラーにしない。""" + SESSION.forget_source(str(source_id)) + return {"ok": True} + + +@_guard("clear_sources") +def clear_sources_json() -> dict: + """すべてのデータ元を取り除く(「置き換えて読み込む」の前)。""" + SESSION.forget() + return {"ok": True} def _step_error(script: GeneratedScript, err: ScriptError) -> ScriptError: @@ -158,10 +240,11 @@ def _step_error(script: GeneratedScript, err: ScriptError) -> ScriptError: def _generate(settings: Settings): - if SESSION.df is None: + if not SESSION.sources: raise UserError("先にファイルを読み込んでください。") - plan = plan_plot(SESSION.df, settings) - return plan, generate_script(settings, _source_info(), plan) + plan = plan_plot({sid: src.df for sid, src in SESSION.sources.items()}, settings) + infos = {sid: src.info for sid, src in SESSION.sources.items()} + return plan, generate_script(settings, infos, plan) def _run(settings: Settings | None, code: str | None, *, file_format: str, transparent: bool, dpi: int, svg_text: str = "path"): @@ -172,7 +255,7 @@ def _run(settings: Settings | None, code: str | None, *, file_format: str, trans try: result = run_to_image( script.auto_render_text(), file_format=file_format, transparent=transparent, dpi=dpi, svg_text=svg_text, - injected={"df": SESSION.df}, + injected={src.var: SESSION.sources[src.id].df for src in plan.sources}, ) except ScriptError as err: raise _step_error(script, err) from err diff --git a/py/mplgui/codegen.py b/py/mplgui/codegen.py index 72de227..39c47e8 100644 --- a/py/mplgui/codegen.py +++ b/py/mplgui/codegen.py @@ -16,7 +16,7 @@ from .dataprep import PlotPlan, SeriesPlan from .fonts import LATIN_FONTS, SANS_SERIF_PRIORITY from .formats import FORMATS, build_filename, savefig_kwargs, savefig_rc -from .loader import SourceInfo +from .loader import DatetimeColumn, SourceInfo from .settings import SeriesSettings, Settings SCRIPT_NAME = "plot.py" @@ -153,15 +153,30 @@ def _label_size_expr(font_size: float) -> str: # ---------------------------------------------------------------- sections -def _emit_header(b: _Builder, source: SourceInfo, latin_font: str = "default") -> None: - name = comment_text(source.filename) +def _emit_header(b: _Builder, sources: list[SourceInfo], latin_font: str = "default", plan: PlotPlan | None = None) -> None: latin = LATIN_FONTS.get(latin_font) + if len(sources) == 1: + source = sources[0] + name = comment_text(source.filename) + if source.pasted: + usage = f"# 使い方: 貼り付けたデータを保存した「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。" + else: + usage = f"# 使い方: データファイル「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。" + else: + names = "".join( + f"「{comment_text(src.filename)}」" + ("(貼り付けたデータを保存したもの)" if src.pasted else "") for src in sources + ) + usage = f"# 使い方: データファイル {names} を、このスクリプトと同じフォルダに置いて、python {SCRIPT_NAME} で実行します。" b.add( "# matplotlib GUI が生成したスクリプト", - f"# 使い方: データファイル「{name}」と同じフォルダに置いて、python {SCRIPT_NAME} で実行します。", + usage, "# 必要なライブラリ: pandas, matplotlib, numpy", - "import matplotlib.pyplot as plt", ) + if plan is not None and _uses_date_axis(plan): + b.add("import matplotlib.dates as mdates") + b.add("import matplotlib.pyplot as plt") + if plan is not None and plan.thin_categorical_x: + b.add("import matplotlib.ticker as ticker") if latin is not None: b.add("from matplotlib import font_manager") b.add( @@ -190,39 +205,90 @@ def _emit_header(b: _Builder, source: SourceInfo, latin_font: str = "default") - b.add(f'plt.rcParams["mathtext.fontset"] = {literal(latin.mathtext_fontset)} # 数式($...$)も Times 系の字形にする(STIX は matplotlib に同梱)') +def _uses_date_axis(plan: PlotPlan) -> bool: + return plan.datetime_x and plan.plot_type in {"line", "scatter"} + + def _font_list_literal() -> str: return "[" + ", ".join(literal(name) for name in SANS_SERIF_PRIORITY) + "]" -def _emit_load(b: _Builder, source: SourceInfo) -> None: - start = b.section("1. データの読み込み") - header_arg = literal(0 if source.has_header else None) - header_note = "あり" if source.has_header else "なし" - if source.kind == "xlsx": - b.add( - f"# 形式: Excel(.xlsx)、シート: {comment_text(source.sheet_name)}(先頭のシート)、先頭行をヘッダにする: {header_note}", - "# .xlsx の読み込みには openpyxl が必要です(pip install openpyxl)", - f"DATA_FILE = {literal(source.filename)}", - "# 前置き行(データの前にある説明の行)を飛ばすときは、read_excel に skiprows=行数 を足す", - f"df = pd.read_excel(DATA_FILE, sheet_name={literal(source.sheet_name)}, header={header_arg})", - ) - else: +# 読込の引数ごとのコメント(SourceInfo.read_kwargs の全てのキーに必要。読込部はこの順・この内容で、全ての引数を明記する) +_READ_COMMENTS = { + "sep": "区切り文字", + "header": "先頭行をヘッダにする(None: ヘッダなし)", + "skiprows": "ヘッダより前に読み飛ばす行数", + "thousands": "桁区切り(None: なし)", + "decimal": "小数点", + "comment": "コメント記号(None: なし)。この記号から行末までを読まない", + "engine": None, + "sheet_name": "シート", +} +_READ_COMMENTS_XLSX = { + "thousands": "桁区切り(文字列のセルだけに効く。None: なし)", + "decimal": "小数点(文字列のセルだけに効く)", +} +_KIND_NOTES = {"csv": "CSV(.csv)", "txt": "テキスト(.txt)", "tsv": "TSV(.tsv)", "xlsx": "Excel(.xlsx)"} + + +def _datetime_expr(spec: DatetimeColumn, df: str = "df") -> str: + """列を日時にする式。loader.convert_datetime と同じ内容(単体テストで一致を確かめる)。""" + source = f"{df}.iloc[:, {spec.index}]" + (".str.strip()" if spec.strip else "") + utc = ", utc=True" if spec.utc else "" + expr = f'pd.to_datetime({source}, format={literal(spec.format)}, errors="coerce"{utc})' + return expr + ".dt.tz_localize(None)" if spec.has_tz else expr + + +def _emit_read(b: _Builder, source: SourceInfo, data_file: str, df: str) -> None: + """1つのファイルの読み込み(DATA_FILE と df の名前は呼び出し側が決める)。""" + xlsx = source.kind == "xlsx" + if source.pasted: b.add( - f"# 文字コード: {comment_text(source.encoding)}(自動判定)、区切り文字: {comment_text(literal(source.separator))}、先頭行をヘッダにする: {header_note}", - f"DATA_FILE = {literal(source.filename)}", - "# 前置き行(データの前にある説明の行)を飛ばすときは、read_csv に skiprows=行数 を足す", - f"df = pd.read_csv(DATA_FILE, encoding={literal(source.encoding)}, sep={literal(source.separator)}, " - f'header={header_arg}, engine="python")', + f"# 貼り付けたデータ: 「データ読み込み」の「貼り付けたデータを保存」で {comment_text(source.filename)} を保存し、" + "このスクリプトと同じフォルダに置いてください。" ) + b.add(f"# 形式: {_KIND_NOTES.get(source.kind, comment_text(source.kind))}") + if xlsx: + b.add("# 読み込みには openpyxl が必要です(pip install openpyxl)") + if source.sheets: + b.add(f"# ファイル内のシート: {comment_text(', '.join(source.sheets))}") + b.add(f"{data_file} = {literal(source.filename)}") + func = "pd.read_excel" if xlsx else "pd.read_csv" + b.add(f"{df} = {func}(", f" {data_file},") + if not xlsx: + b.add(f" encoding={literal(source.encoding)}, # 文字コード(自動判定)") + for key, value in source.read_kwargs().items(): + note = (_READ_COMMENTS_XLSX.get(key) if xlsx else None) or _READ_COMMENTS[key] + line = f" {key}={literal(value)}," + b.add(f"{line} # {note}" if note else line) + b.add(")") if not source.has_header: - b.add('df.columns = [f"column_{i}" for i in range(len(df.columns))] # 列名を column_0, column_1, ... にする') + b.add(f'{df}.columns = [f"column_{{i}}" for i in range(len({df}.columns))] # 列名を column_0, column_1, ... にする') + if source.datetime_columns: + b.add("# 日時の列を日時に変換する(自動で認識した書式。読めない値は NaT(欠損)になる)") + for spec in source.datetime_columns: + b.add(f"{df}.isetitem({spec.index}, {_datetime_expr(spec, df)}) # {comment_text(spec.name)} [{spec.index}]") + + +def _emit_load(b: _Builder, plan: PlotPlan, infos: list[SourceInfo]) -> None: + start = b.section("1. データの読み込み") + if not plan.multi_source: + _emit_read(b, infos[0], "DATA_FILE", "df") + else: + for i, (src, info) in enumerate(zip(plan.sources, infos)): + if i > 0: + b.blank() + b.add(f"# データ{src.number}: {comment_text(info.filename)}") + _emit_read(b, info, f"DATA_FILE_{src.number}", src.var) b.load_lines = (start, len(b.lines)) def _emit_skip_rows(b: _Builder, plan: PlotPlan) -> None: b.section("2. 描画に使う行") if plan.skip_rows > 0: - b.add(f"df = df.iloc[{literal(plan.skip_rows)}:].reset_index(drop=True) # 先頭の{plan.skip_rows}行を描画から除外する") + for src in plan.sources: + v = src.var + b.add(f"{v} = {v}.iloc[{literal(plan.skip_rows)}:].reset_index(drop=True) # 先頭の{plan.skip_rows}行を描画から除外する") else: b.add("# 先頭の行は除外しない(全ての行を使う)") @@ -247,6 +313,8 @@ def _emit_figure(b: _Builder, settings: Settings) -> None: def _series_comment(p: SeriesPlan, *, bar_x: bool = False) -> str: where = "(第2Y軸)" if p.secondary else "" + if p.data_var != "df": + where += f"({p.data_var})" return f"# 系列{p.number}{where}: X = {_column_comment(p, 'x')}、Y = {_column_comment(p, 'y')}" @@ -256,15 +324,17 @@ def _target(p: SeriesPlan) -> str: def _emit_x_data(b: _Builder, p: SeriesPlan) -> None: if p.x_index is None: - b.add("x = pd.Series(df.index) # X は指定なし。行番号を使う") + b.add(f"x = pd.Series({p.data_var}.index) # X は指定なし。行番号を使う") + elif p.datetime_x: + b.add(f"x = {p.data_var}.iloc[:, {p.x_index}] # 日時の列(読み込み時に日時に変換済み)") elif p.convert_x: - b.add(f'x = pd.to_numeric(df.iloc[:, {p.x_index}], errors="coerce") # 数値にできない値は NaN にする') + b.add(f'x = pd.to_numeric({p.data_var}.iloc[:, {p.x_index}], errors="coerce") # 数値にできない値は NaN にする') else: - b.add(f"x = df.iloc[:, {p.x_index}]") + b.add(f"x = {p.data_var}.iloc[:, {p.x_index}]") def _emit_y_data(b: _Builder, p: SeriesPlan) -> None: - b.add(f'y = pd.to_numeric(df.iloc[:, {p.y_index}], errors="coerce") # 数値にできない値は NaN にする') + b.add(f'y = pd.to_numeric({p.data_var}.iloc[:, {p.y_index}], errors="coerce") # 数値にできない値は NaN にする') def _emit_ok_mask(b: _Builder) -> None: @@ -329,10 +399,16 @@ def _emit_scatter_series(b: _Builder, settings: Settings, plan: PlotPlan) -> Non def _emit_bar_x(b: _Builder, plan: PlotPlan) -> None: + df = plan.series[0].data_var # 棒グラフは全系列が同じデータ元 if plan.bar_x_index is None: - b.add("x = pd.Series(df.index) # X は指定なし。行番号を使う") + b.add(f"x = pd.Series({df}.index) # X は指定なし。行番号を使う") + elif plan.datetime_x: + b.add( + f"x = {df}.iloc[:, {plan.bar_x_index}].dt.strftime({literal(plan.bar_date_format)})" + " # 日時を文字列にして、棒をカテゴリとして並べる" + ) else: - b.add(f"x = df.iloc[:, {plan.bar_x_index}]") + b.add(f"x = {df}.iloc[:, {plan.bar_x_index}]") def _emit_bar_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None: @@ -355,7 +431,7 @@ def _emit_bar_series(b: _Builder, settings: Settings, plan: PlotPlan) -> None: b.add("bar_data = pd.DataFrame({") b.add(' "x": x,') for p in plan.series: - b.add(f' "y{p.number}": pd.to_numeric(df.iloc[:, {p.y_index}], errors="coerce"), # {_column_comment(p, "y")}') + b.add(f' "y{p.number}": pd.to_numeric({p.data_var}.iloc[:, {p.y_index}], errors="coerce"), # {_column_comment(p, "y")}') b.add("})") keys = ", ".join(literal(f"y{p.number}") for p in plan.series) b.add(f'bar_data = bar_data.dropna(subset=[{keys}], how="all") # 全ての系列が欠けている行は描かない') @@ -395,6 +471,28 @@ def _axis_lines(var: str, which: str, scale: str, lo, hi) -> list[str]: return lines +def _x_tick_lines(settings: Settings, plan: PlotPlan) -> list[str]: + """X 軸の目盛の書式(日時の軸、文字列の列の間引き)。line / scatter だけ。""" + if plan.plot_type not in {"line", "scatter"}: + return [] + if _uses_date_axis(plan): + fmt = settings.axes.x.date_format + if fmt: + return [f"ax.xaxis.set_major_formatter(mdates.DateFormatter({literal(fmt)})) # 日時の表示書式"] + return [ + "# X が日時の列: 範囲に合わせて目盛の間隔と表示を自動で決める", + "date_locator = mdates.AutoDateLocator()", + "ax.xaxis.set_major_locator(date_locator)", + "ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(date_locator))", + ] + if plan.thin_categorical_x: + return [ + "# X が文字列の列: 目盛に X の値を出す(25個を超えるので間引く)", + "ax.xaxis.set_major_locator(ticker.MaxNLocator(nbins=25, integer=True))", + ] + return [] + + def _emit_axes(b: _Builder, settings: Settings, plan: PlotPlan) -> None: b.section("5. 軸(ラベル・スケール・範囲・目盛)") plot, axes = settings.plot, settings.axes @@ -413,8 +511,8 @@ def _emit_axes(b: _Builder, settings: Settings, plan: PlotPlan) -> None: if var == "ax2" and not plan.uses_secondary: continue lines = _axis_lines(var, which, axis.scale, axis.min, axis.max) - if var == "ax" and which == "x" and axis.scale == "linear" and any(p.categorical_x and p.has_points for p in plan.series): - lines.insert(0, 'ax.set_xscale("linear") # X が文字列・日時の列: 目盛は位置の番号になる(値を目盛にするには、この行を消す)') + if var == "ax" and which == "x": + lines = _x_tick_lines(settings, plan) + lines if lines: with b.step(axis_error_message(label), f"{label}軸の範囲"): b.add(*lines) @@ -525,12 +623,17 @@ def _emit_output(b: _Builder, settings: Settings) -> None: # ---------------------------------------------------------------- entry -def generate_script(settings: Settings, source: SourceInfo, plan: PlotPlan) -> GeneratedScript: - """設定・読み込んだファイルの情報・描画計画から、完全なスクリプトを作る。""" +def generate_script(settings: Settings, source, plan: PlotPlan) -> GeneratedScript: + """設定・読み込んだファイルの情報・描画計画から、完全なスクリプトを作る。 + + source は SourceInfo(1つ)または {ファイル id: SourceInfo}(plan.sources のすべてを含む)。""" + if isinstance(source, SourceInfo): + source = {src.id: source for src in plan.sources} + infos = [source[src.id] for src in plan.sources] b = _Builder() - _emit_header(b, source, settings.plot.latin_font) + _emit_header(b, infos, settings.plot.latin_font, plan) b.add(f"FONT_SIZE = {literal(settings.plot.font_size)} # 文字の大きさ(pt)") - _emit_load(b, source) + _emit_load(b, plan, infos) _emit_skip_rows(b, plan) _emit_figure(b, settings) _emit_series(b, settings, plan) diff --git a/py/mplgui/dataprep.py b/py/mplgui/dataprep.py index 80a3fbb..2d99503 100644 --- a/py/mplgui/dataprep.py +++ b/py/mplgui/dataprep.py @@ -6,7 +6,8 @@ """ from __future__ import annotations -from dataclasses import dataclass +import re +from dataclasses import dataclass, replace import pandas as pd @@ -52,9 +53,29 @@ def coerce_numeric(raw: pd.Series) -> tuple[pd.Series, int, list[str]]: return numeric, count, examples -def dropped_warning(series_no: int, what: str, count: int, examples: list[str]) -> dict: +_THOUSANDS_LIKE = re.compile(r"[+-]?\d{1,3}(,\d{3})+(\.\d+)?") # 1,234 / -1,234,567.5 +_DECIMAL_COMMA_LIKE = re.compile(r"[+-]?(\d+,\d+|\d{1,3}(\.\d{3})+,\d+)") # 1,5 / 1.234,5 +THOUSANDS_HINT = "(桁区切りのカンマなら、「データ読み込み」の「桁区切り」をカンマにしてください)" +DECIMAL_HINT = "(小数点がカンマなら、「データ読み込み」の「小数点」をカンマにしてください)" + + +def load_hint(examples: list[str], load) -> str: + """除外した値の例が、桁区切り・小数点カンマの数に見えるのに読込設定がそうなっていないとき、設定の案内を返す。""" + if load is None: + return "" + for text in examples: + value = text.strip() + if _THOUSANDS_LIKE.fullmatch(value): # 両方に当てはまる 1,234 は桁区切りとして案内する + if load.thousands != ",": + return THOUSANDS_HINT + elif _DECIMAL_COMMA_LIKE.fullmatch(value) and load.decimal != ",": + return DECIMAL_HINT + return "" + + +def dropped_warning(series_no: int, what: str, count: int, examples: list[str], load=None) -> dict: ex = ", ".join(f'"{e}"' for e in examples) - message = f"{what}: 数値に変換できない値が{count}件あったため、その行を除外しました(例: {ex})。" + message = f"{what}: 数値に変換できない値が{count}件あったため、その行を除外しました(例: {ex})。{load_hint(examples, load)}" return {"series": series_no, "message": message} @@ -126,9 +147,13 @@ def resolve_y_indices(df: pd.DataFrame, requests: list[str]) -> list[str]: return resolved -def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int) -> pd.DataFrame: +def slice_skip_rows(df_full: pd.DataFrame, skip_rows: int, label: str = "") -> pd.DataFrame: + """label は複数ファイルのときのデータ元の名前(「データ2(b.csv)」)。1つのときは空。""" if skip_rows >= len(df_full): - raise UserError("スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数") + prefix = f"{label}: " if label else "" + raise UserError( + f"{prefix}スキップ行数がデータ行数以上です。「除外する先頭行数」を小さくしてください。", field="除外する先頭行数" + ) return df_full.iloc[skip_rows:].reset_index(drop=True) @@ -145,10 +170,23 @@ class SeriesPlan: data_label: str # f"{name} [{idx}]" legend_label: str convert_x: bool # X を pd.to_numeric で変換する(line / scatter のみ) - categorical_x: bool # line / scatter で X が数値でない列(文字列・日時)のまま使う + categorical_x: bool # line / scatter で X が数値でも日時でもない列(文字列)のまま使う marker_size: float has_points: bool secondary: bool + datetime_x: bool = False # X が日時の列(読み込み時に日時に変換済み) + data_id: str = "" # データ元のファイル id + data_var: str = "df" # 生成コードでこの系列が使う DataFrame の変数名(複数ファイルのときは df1, df2, ...) + + +@dataclass(frozen=True) +class PlanSource: + """描画に使うデータ元(load.files の並びの順)。""" + + id: str + number: int # 「データN」の N(load.files の位置。ファイルの一覧が空なら 1) + var: str # DataFrame の変数名("df" または "dfN") + name: str # ファイル名(load.files の name。無ければ空) @dataclass(frozen=True) @@ -163,47 +201,120 @@ class PlotPlan: plotted_count: int uses_secondary: bool # 第2Y軸(ax2)を作る(描ける点のある第2軸の系列がある) warnings: tuple[dict, ...] # [{"series": 1始まり, "message": str}] + datetime_x: bool = False # 日時の X 軸(line / scatter は日時の軸、bar は日時を文字列にしたカテゴリ) + thin_categorical_x: bool = False # line / scatter で X が文字列の列。カテゴリが多いので目盛を間引く + bar_date_format: str | None = None # bar で X が日時のときの strftime の書式 + sources: tuple[PlanSource, ...] = () # 描画に使うデータ元だけ(load.files の順) + multi_source: bool = False # load.files が2つ以上(生成コードで DATA_FILE_N / dfN と書く) @property def plotted(self) -> tuple[SeriesPlan, ...]: return tuple(s for s in self.series if s.has_points) +MAX_CATEGORY_TICKS = 25 # X が文字列の列のとき、目盛に出すカテゴリの最大数 +Y_DATETIME_MESSAGE = "系列{n}: Y列({name})は日時の列です。Y列には数値の列を選んでください。" + + +def _is_datetime(series: pd.Series) -> bool: + return pd.api.types.is_datetime64_any_dtype(series.dtype) + + +def auto_bar_date_format(x: pd.Series) -> str: + """棒グラフの X が日時のときの、自動の書式。全部 0 時なら日付だけ、秒がすべて 0 なら分まで、それ以外は秒まで。""" + valid = x.dropna() + if valid.empty or (valid == valid.dt.normalize()).all(): + return "%Y-%m-%d" + if (valid.dt.second == 0).all() and (valid.dt.microsecond == 0).all(): + return "%Y-%m-%d %H:%M" + return "%Y-%m-%d %H:%M:%S" + + def _label_for(df: pd.DataFrame, idx: int) -> tuple[str, str]: name = str(df.columns[idx]) return name, f"{name} [{idx}]" -def plan_plot(df_full: pd.DataFrame, settings: Settings) -> PlotPlan: - """データと設定から描画計画を作る。データ由来の問題は UserError にする。""" +NOT_LOADED_MESSAGE = "データ{n}({name})を読み込めていません。読込設定やファイルを確認してください。" +BAR_SOURCE_MESSAGE = "棒グラフでは、すべての系列に同じデータ元を選んでください。" + + +def plan_plot(data, settings: Settings) -> PlotPlan: + """データと設定から描画計画を作る。データ由来の問題は UserError にする。 + + data は DataFrame(1つ)または {ファイル id: DataFrame}(読み込めたファイルだけ)。""" plot = settings.plot - df = slice_skip_rows(df_full, plot.skip_rows) - y_requests = resolve_y_indices(df, [s.y for s in settings.series]) + files = settings.load.files + numbers = {f.id: i + 1 for i, f in enumerate(files)} + names = {f.id: f.name for f in files} + if isinstance(data, pd.DataFrame): + data = {files[0].id if files else "d1": data} + if not data: + raise UserError("先にファイルを読み込んでください。") + default_id = files[0].id if files else next(iter(data)) + multi = len(files) >= 2 + + ids: list[str] = [] # 系列ごとのデータ元 + for n, s in enumerate(settings.series, start=1): + sid = s.source or default_id + if sid not in data: + raise UserError( + NOT_LOADED_MESSAGE.format(n=numbers.get(sid, 1), name=names.get(sid, "")), field=f"系列{n}のデータ元" + ) + ids.append(sid) + if plot.type == "bar" and len(set(ids)) > 1: + raise UserError(BAR_SOURCE_MESSAGE, field="データ元") + + order = [f.id for f in files if f.id in set(ids)] if files else list(dict.fromkeys(ids)) + frames = { + sid: slice_skip_rows(data[sid], plot.skip_rows, f"データ{numbers.get(sid, 1)}({names.get(sid, '')})" if multi else "") + for sid in order + } + sources = tuple( + PlanSource(id=sid, number=numbers.get(sid, 1), var=f"df{numbers[sid]}" if multi else "df", name=names.get(sid, "")) + for sid in order + ) + var_of = {src.id: src.var for src in sources} + + y_requests = [""] * len(settings.series) + for sid in order: + idx = [i for i, x in enumerate(ids) if x == sid] + resolved = resolve_y_indices(frames[sid], [settings.series[i].y for i in idx]) + for i, r in zip(idx, resolved): + y_requests[i] = r + dfs = [frames[sid] for sid in ids] if plot.type in {"line", "scatter"}: - return _plan_xy(df, settings, y_requests) - return _plan_bar(df, settings, y_requests) + plan = _plan_xy(dfs, settings, y_requests) + else: + plan = _plan_bar(dfs[0], settings, y_requests) + series = tuple(replace(sp, data_id=sid, data_var=var_of[sid]) for sp, sid in zip(plan.series, ids)) + return replace(plan, series=series, sources=sources, multi_source=multi) -def _plan_xy(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> PlotPlan: +def _plan_xy(dfs: list[pd.DataFrame], settings: Settings, y_requests: list[str]) -> PlotPlan: plot_type = settings.plot.type plans: list[SeriesPlan] = [] warnings: list[dict] = [] - for n, (series, y_request) in enumerate(zip(settings.series, y_requests), start=1): + category_values: set = set() + for n, (df, series, y_request) in enumerate(zip(dfs, settings.series, y_requests), start=1): x_index = resolve_column_index(df, series.x) if series.x else None x_label = str(df.columns[x_index]) if x_index is not None else "index" y_index = resolve_column_index(df, y_request) y_name, data_label = _label_for(df, y_index) + if _is_datetime(df.iloc[:, y_index]): + raise UserError(Y_DATETIME_MESSAGE.format(n=n, name=y_name), field=f"系列{n}のY列") x_series = df.iloc[:, x_index] if x_index is not None else pd.Series(df.index) + datetime_x = _is_datetime(x_series) convert_x = False - if x_index is not None and not pd.api.types.is_numeric_dtype(x_series.dtype) and _looks_numeric(x_series): + if x_index is not None and not datetime_x and not pd.api.types.is_numeric_dtype(x_series.dtype) and _looks_numeric(x_series): convert_x = True x_series, x_dropped, x_examples = coerce_numeric(x_series) if x_dropped: - warnings.append(dropped_warning(n, f"系列{n}のX列({x_label})", x_dropped, x_examples)) + warnings.append(dropped_warning(n, f"系列{n}のX列({x_label})", x_dropped, x_examples, settings.load)) y_numeric, y_dropped, y_examples = coerce_numeric(df.iloc[:, y_index]) if y_dropped: - warnings.append(dropped_warning(n, f"系列{n}({y_name})", y_dropped, y_examples)) + warnings.append(dropped_warning(n, f"系列{n}({y_name})", y_dropped, y_examples, settings.load)) has_points = bool((y_numeric.notna() & pd.notna(x_series)).any()) plans.append( @@ -216,19 +327,45 @@ def _plan_xy(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Plo data_label=data_label, legend_label=series.label or data_label, convert_x=convert_x, - categorical_x=x_index is not None and not convert_x and not pd.api.types.is_numeric_dtype(x_series.dtype), + categorical_x=x_index is not None + and not datetime_x + and not convert_x + and not pd.api.types.is_numeric_dtype(x_series.dtype), marker_size=series.effective_marker_size(plot_type), has_points=has_points, secondary=series.secondary_axis, + datetime_x=datetime_x, ) ) + if has_points and x_index is not None and plans[-1].categorical_x: + shown = x_series[y_numeric.notna() & x_series.notna()] + category_values.update(shown.tolist()) if not any(p.has_points for p in plans): raise UserError(_no_data_message(warnings)) + _check_datetime_x(settings, plans) x_labels = [p.x_label for p in plans] common_x = x_labels[0] if len(set(x_labels)) == 1 else "x" - return _finish(settings, plans, warnings, common_x, None) + return _finish( + settings, plans, warnings, common_x, None, + datetime_x=any(p.datetime_x and p.has_points for p in plans), + thin_categorical_x=len(category_values) > MAX_CATEGORY_TICKS, + ) + + +def _check_datetime_x(settings: Settings, plans: list[SeriesPlan]) -> None: + """日時の X 軸の組み合わせの検査(日時と日時でない X の混在、対数軸、範囲の指定)。""" + kinds = {p.datetime_x for p in plans} + if kinds == {True, False}: + raise UserError("X が日時の系列と、日時でない系列を同じ図に描くことはできません。X列をそろえてください。", field="X列") + if kinds != {True}: + return + axis = settings.axes.x + if axis.scale == "log": + raise UserError("X が日時の列のときは、X軸を対数にできません。", field="X軸スケール") + if axis.min is not None or axis.max is not None: + raise UserError("X が日時の列のときは、X軸の最小値・最大値は指定できません。空欄にしてください。", field="X軸の範囲") def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> PlotPlan: @@ -238,10 +375,12 @@ def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Pl bar_x_index = resolve_column_index(df, x_req) x = df.iloc[:, bar_x_index] x_label = str(df.columns[bar_x_index]) + datetime_x = _is_datetime(x) else: bar_x_index = None x = pd.Series(df.index) x_label = "index" + datetime_x = False frame = pd.DataFrame({"x": x}) plans: list[SeriesPlan] = [] @@ -250,10 +389,12 @@ def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Pl n = i + 1 y_index = resolve_column_index(df, y_request) y_name, data_label = _label_for(df, y_index) + if _is_datetime(df.iloc[:, y_index]): + raise UserError(Y_DATETIME_MESSAGE.format(n=n, name=y_name), field=f"系列{n}のY列") numeric, dropped, examples = coerce_numeric(df.iloc[:, y_index]) frame[f"y_{i}"] = numeric if dropped: - warnings.append(dropped_warning(n, f"系列{n}({y_name})", dropped, examples)) + warnings.append(dropped_warning(n, f"系列{n}({y_name})", dropped, examples, settings.load)) plans.append( SeriesPlan( number=n, @@ -268,6 +409,7 @@ def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Pl marker_size=series.effective_marker_size("bar"), has_points=True, secondary=series.secondary_axis, + datetime_x=datetime_x, ) ) @@ -280,10 +422,23 @@ def _plan_bar(df: pd.DataFrame, settings: Settings, y_requests: list[str]) -> Pl has_points = bool((frame["y_0"].notna() & pd.notna(frame["x"])).any()) if not has_points: raise UserError(NO_DRAWABLE_DATA) - return _finish(settings, plans, warnings, x_label, bar_x_index) - - -def _finish(settings: Settings, plans: list[SeriesPlan], warnings: list[dict], x_label: str, bar_x_index) -> PlotPlan: + bar_date_format = None + if datetime_x: + bar_date_format = settings.axes.x.date_format or auto_bar_date_format(x) + return _finish(settings, plans, warnings, x_label, bar_x_index, datetime_x=datetime_x, bar_date_format=bar_date_format) + + +def _finish( + settings: Settings, + plans: list[SeriesPlan], + warnings: list[dict], + x_label: str, + bar_x_index, + *, + datetime_x: bool = False, + thin_categorical_x: bool = False, + bar_date_format: str | None = None, +) -> PlotPlan: primary = [p.data_label for p in plans if not p.secondary] secondary = [p.data_label for p in plans if p.secondary] uses_secondary = any(p.secondary and p.has_points for p in plans) @@ -298,4 +453,7 @@ def _finish(settings: Settings, plans: list[SeriesPlan], warnings: list[dict], x plotted_count=sum(1 for p in plans if p.has_points), uses_secondary=uses_secondary, warnings=tuple(warnings), + datetime_x=datetime_x, + thin_categorical_x=thin_categorical_x, + bar_date_format=bar_date_format, ) diff --git a/py/mplgui/loader.py b/py/mplgui/loader.py index 2590062..2e8d056 100644 --- a/py/mplgui/loader.py +++ b/py/mplgui/loader.py @@ -2,18 +2,20 @@ from __future__ import annotations import csv +import dataclasses import io import re import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import pandas as pd from .errors import UserError -SUPPORTED = {".xlsx", ".csv", ".txt"} +SUPPORTED = {".xlsx", ".csv", ".txt", ".tsv"} PREVIEW_ROWS = 100 +PREAMBLE_LINES = 20 # 前置き行として画面に出す最大の行数 @dataclass(frozen=True) @@ -21,11 +23,46 @@ class SourceInfo: """読み込んだファイルの情報。codegen が生成スクリプトの「データの読み込み」部に使う。""" filename: str - kind: str # "csv" | "txt" | "xlsx" + kind: str # "csv" | "txt" | "tsv" | "xlsx" encoding: str | None = None # xlsx は None separator: str | None = None # デコード後の区切り文字(csv / txt)。xlsx は None has_header: bool = True - sheet_name: str | None = None # xlsx のみ(先頭シート) + sheet_name: str | None = None # xlsx のみ(読んだシート) + skip_lines: int = 0 # ヘッダより前に読み飛ばす行数 + thousands: str | None = None # None = なし + decimal: str = "." + comment: str | None = None # None = なし + sheets: tuple[str, ...] = () # xlsx のシート一覧 + datetime_columns: tuple["DatetimeColumn", ...] = () # 読込のあとで日時に変換した列(生成コードの読込部に変換の行を書く) + pasted: bool = False # 貼り付けたデータ(pasted_data.tsv)。生成コードに保存の案内を書く + + def read_kwargs(self) -> dict: + """pd.read_csv / pd.read_excel に渡す引数(文字コードとファイルを除く)。順序は生成スクリプトの並びと同じ。 + + loader と codegen が同じ定義を使う(読み方が食い違わないようにする)。 + """ + common = { + "header": 0 if self.has_header else None, + "skiprows": self.skip_lines, + "thousands": self.thousands, + "decimal": self.decimal, + "comment": self.comment, + } + if self.kind == "xlsx": + return {"sheet_name": self.sheet_name, **common} + return {"sep": self.separator, **common, "engine": "python"} + + +@dataclass(frozen=True) +class DatetimeColumn: + """自動で日時と認識した文字列の列。変換の式は convert_datetime(loader)と codegen が同じ内容で持つ。""" + + index: int # 列番号 + name: str # 列名(コメント・警告用) + format: str # pd.to_datetime の format + strip: bool = False # 値に前後の空白があるので .str.strip() してから変換する + utc: bool = False # タイムゾーンが混在しているので utc=True で UTC にそろえる + has_tz: bool = False # format に %z を含む(変換後に .dt.tz_localize(None) で書かれた時刻にする) @dataclass @@ -33,6 +70,9 @@ class LoadedData: df: pd.DataFrame encoding: str | None # xlsx は None source: SourceInfo | None = None + warnings: list[str] = field(default_factory=list) # 読込時の警告(画面の警告に出す) + preamble_lines: tuple[str, ...] = () # 読み飛ばした行(最大 PREAMBLE_LINES 行) + preamble_total: int = 0 # 読み飛ばした行数 # バックスロッシュ表記(\t, \x41, \u3001 など)だけを解釈する。それ以外(「、」などの非 ASCII 文字)は変更しない。 @@ -45,7 +85,8 @@ def repl(match: re.Match) -> str: if not token.isascii(): return token with warnings.catch_warnings(): - warnings.simplefilter("ignore") + # 「\d」のような未定義のエスケープは、文字のまま残る("\\d")が DeprecationWarning が出る。これだけを無視する + warnings.filterwarnings("ignore", category=DeprecationWarning, message=r".*invalid escape sequence") return token.encode("ascii").decode("unicode_escape") return _ESCAPE.sub(repl, text) @@ -63,6 +104,8 @@ def decode_delimiter(delimiter: str, suffix: str) -> str: ) from exc if suffix == ".csv": return "," + if suffix == ".tsv": + return "\t" return r"\s+" @@ -123,61 +166,312 @@ def detect_encoding(data: bytes) -> tuple[str, str]: return min(candidates, key=lambda c: _suspicion(c[1])) # min は同点で先頭を返す -def load_file(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> LoadedData: +SKIP_LINES_HINT = "ファイルの先頭に説明の行があるときは、「ヘッダより前に読み飛ばす行数」を指定してください。" +SKIP_LINES_TOO_LARGE_HINT = "「ヘッダより前に読み飛ばす行数」が大きすぎないか確認してください。" +DELIMITER_HINT = "列が1つしか読み込めませんでした。区切り文字(; やタブなど)を「区切り文字」に指定してください。" +_EXCEL_OPEN_ERROR = ( + "Excelファイル(.xlsx)を読み込めませんでした。ファイルが壊れていないか、パスワードが掛かっていないかを確認し、" + "Excelで開いて .xlsx で保存し直してください。" +) + + +def open_excel(file_bytes: bytes) -> pd.ExcelFile: + """xlsx を開く(シートの切り替えで開き直さないように、呼び出し側が保持する)。""" + try: + return pd.ExcelFile(io.BytesIO(file_bytes)) + except Exception as exc: + raise UserError(_EXCEL_OPEN_ERROR, field="入力ファイル", detail=f"{type(exc).__name__}: {exc}") from exc + + +def _check_conflicts(sep: str, thousands: str, comment: str) -> None: + if thousands == " " and sep == r"\s+": + raise UserError("区切り文字が空白のときは、桁区切りに空白は使えません。", field="桁区切り") + if comment and comment == sep: + raise UserError("「コメント記号」に、区切り文字と同じ記号は使えません。", field="コメント記号") + + +def split_lines(text: str) -> list[str]: + """pandas と同じく \\r・\\n・\\r\\n だけを行の区切りにする(str.splitlines は U+2028 や改ページでも切る)。""" + return re.split(r"\r\n|\r|\n", text) + + +def _decimal_separator_warning(sep: str, decimal: str) -> str | None: + if len(sep) == 1 and decimal == sep: + shown = "タブ" if sep == "\t" else sep + return ( + f"小数点と区切り文字が同じ記号({shown})です。値が引用符(\"1,5\" のように)で囲まれていないと、" + "正しく読めません。区切り文字が ; やタブのファイルなら、「区切り文字」に指定してください。" + ) + return None + + +def _first_data_line(text: str, skip_lines: int, comment: str) -> str: + for line in split_lines(text)[skip_lines:]: + if line.strip() and not (comment and line.lstrip().startswith(comment)): + return line + return "" + + +def _delimiter_warning(text: str, sep: str, skip_lines: int, comment: str) -> str | None: + """1 列しか読めなかったとき、先頭のデータ行に ; かタブがあれば、区切り文字の指定漏れとして案内する。""" + line = _first_data_line(text, skip_lines, comment) + for candidate in (";", "\t"): + if candidate in line and sep != candidate: + return DELIMITER_HINT + return None + + +def _excel_preamble(excel: pd.ExcelFile, sheet: str, skip_lines: int) -> tuple[str, ...]: + head = excel.parse(sheet_name=sheet, header=None, nrows=min(skip_lines, PREAMBLE_LINES)) + lines = [] + for row in head.itertuples(index=False, name=None): + cells = ["" if pd.isna(v) else str(v) for v in row] + lines.append("\t".join(cells).rstrip("\t")) + return tuple(lines) + + +# ---------------------------------------------------------------- datetime recognition (D5) + +_DATE_PARTS = ("%Y-%m-%d", "%Y/%m/%d", "%Y年%m月%d日") # 年が先頭のものだけ(月日の順が曖昧な 01/02/2024 は認識しない) +_TIME_PARTS = ("", " %H:%M", " %H:%M:%S", " %H:%M:%S.%f") +_ISO_TIME_PARTS = ("T%H:%M", "T%H:%M:%S", "T%H:%M:%S.%f") + + +def _candidate_formats() -> tuple[str, ...]: + out: list[str] = [] + for date in _DATE_PARTS: + out.extend(date + t for t in _TIME_PARTS) + if date == "%Y-%m-%d": + out.extend(date + t for t in _ISO_TIME_PARTS) + # 時刻のあるもの(スペース区切り・T 区切り)は、末尾にタイムゾーン(Z / +09:00)が付いた書式も候補にする + out.extend(date + t + "%z" for t in _TIME_PARTS[1:] + _ISO_TIME_PARTS) + return tuple(out) + + +DATETIME_FORMATS = _candidate_formats() +DATETIME_SAMPLE = 100 # 書式を選ぶために調べる、先頭からの値の数 +DATETIME_MIN_RATIO = 0.9 # 先頭の値・全体の、それぞれで読めなければならない割合 +_OFFSET = re.compile(r"(Z|[+-]\d{2}(?::?\d{2})?)$") + + +def _is_blank(value) -> bool: + if value is None: + return True + try: + if pd.isna(value): + return True + except (TypeError, ValueError): + pass + return isinstance(value, str) and value.strip() == "" + + +def _offset_of(text: str) -> str: + m = _OFFSET.search(text) + if not m: + return "" + o = m.group(1).replace(":", "") + if o == "Z": + return "+0000" + return o if len(o) == 5 else o + "00" + + +def _parses(values: list[str], fmt: str) -> int: + """fmt で読める値の数(%z の書式は、オフセットが混在していても読めるものとして数える)。""" + kwargs = {"utc": True} if "%z" in fmt else {} + try: + return int(pd.to_datetime(pd.Series(values, dtype=object), format=fmt, errors="coerce", **kwargs).notna().sum()) + except (ValueError, TypeError, OverflowError): + return 0 + + +def _detect_one(index: int, name, column: pd.Series) -> DatetimeColumn | None: + if not (pd.api.types.is_object_dtype(column.dtype) or pd.api.types.is_string_dtype(column.dtype)): + return None + raw = [v for v in column.tolist() if not _is_blank(v)] + if not raw or any(not isinstance(v, str) for v in raw): + return None + texts = [v.strip() for v in raw] + numeric = pd.to_numeric(pd.Series(texts, dtype=object), errors="coerce").notna().sum() + if numeric * 2 >= len(texts): + return None + sample = texts[:DATETIME_SAMPLE] + # 先頭 100 個のうち 90% 以上が読める書式のうち、最も多く読めるもの(同数なら一覧の先に並べたもの) + best_fmt, best_count = None, -1 + for fmt in DATETIME_FORMATS: + count = _parses(sample, fmt) + if count >= DATETIME_MIN_RATIO * len(sample) and count > best_count: + best_fmt, best_count = fmt, count + if best_fmt is None or _parses(texts, best_fmt) < DATETIME_MIN_RATIO * len(texts): + return None + fmt = best_fmt + has_tz = "%z" in fmt + utc = has_tz and len({_offset_of(t) for t in texts if _OFFSET.search(t)}) > 1 + strip = any(v != v.strip() for v in raw) + return DatetimeColumn(index=index, name=str(name), format=fmt, strip=strip, utc=utc, has_tz=has_tz) + + +def detect_datetime_columns(df: pd.DataFrame) -> tuple[DatetimeColumn, ...]: + """日時として読める文字列の列を探す(df は変更しない)。採用条件は、前後の空白を除いた先頭 100 個の + 空欄以外の値の 90% 以上が読める書式があり、かつ空欄以外の全体の 90% 以上がその書式で読めること。数値の列(半数以上が数値)は除く。""" + found = [] + for i in range(df.shape[1]): + spec = _detect_one(i, df.columns[i], df.iloc[:, i]) + if spec is not None: + found.append(spec) + return tuple(found) + + +def convert_datetime(series: pd.Series, spec: DatetimeColumn) -> pd.Series: + """列を日時に変換する。生成コードの `df.isetitem(i, pd.to_datetime(...))` の式と同じ内容(codegen が文字列にする)。""" + source = series.str.strip() if spec.strip else series + kwargs = {"utc": True} if spec.utc else {} + out = pd.to_datetime(source, format=spec.format, errors="coerce", **kwargs) + return out.dt.tz_localize(None) if spec.has_tz else out + + +def apply_datetime_columns(df: pd.DataFrame, specs: tuple[DatetimeColumn, ...]) -> list[str]: + """df の列を(その場で)日時に変換し、警告の文を返す(読めない値は NaT になる)。""" + warnings_out: list[str] = [] + for spec in specs: + original = df.iloc[:, spec.index] + converted = convert_datetime(original, spec) + df.isetitem(spec.index, converted) + where = f"列「{spec.name}」[{spec.index}]" + if spec.utc: + warnings_out.append(f"{where}: タイムゾーンの異なる日時が混在しているため、UTC の時刻にそろえました。") + failed = [str(v).strip() for v, ok in zip(original.tolist(), converted.notna().tolist()) if not ok and not _is_blank(v)] + if failed: + examples: list[str] = [] + for text in failed: + if text not in examples: + examples.append(text) + if len(examples) >= 3: + break + ex = ", ".join(f'"{e}"' for e in examples) + warnings_out.append(f"{where}: 日時として読めない値が{len(failed)}件あったため、欠損として扱います(例: {ex})。") + return warnings_out + + +def column_kind(dtype) -> str: + if pd.api.types.is_datetime64_any_dtype(dtype): + return "datetime" + if pd.api.types.is_numeric_dtype(dtype) and not pd.api.types.is_bool_dtype(dtype): + return "number" + return "text" + + +def load_file( + file_bytes: bytes, + filename: str, + delimiter: str = "", + has_header: bool = True, + *, + skip_lines: int = 0, + thousands: str = "", + decimal: str = ".", + comment: str = "", + sheet: str = "", + excel: pd.ExcelFile | None = None, + parse_dates: bool = True, +) -> LoadedData: suffix = Path(filename).suffix.lower() if suffix not in SUPPORTED: - raise UserError("対応していない拡張子です。xlsx/csv/txtを選択してください。", field="入力ファイル") + raise UserError("対応していない拡張子です。xlsx/csv/txt/tsvを選択してください。", field="入力ファイル") - header = 0 if has_header else None used_encoding: str | None = None sep: str | None = None sheet_name: str | None = None + sheets: tuple[str, ...] = () + text = "" + warnings_out: list[str] = [] + preamble: tuple[str, ...] = () + + def make_source() -> SourceInfo: + return SourceInfo( + filename=Path(filename).name, + kind=suffix.lstrip("."), + encoding=used_encoding, + separator=sep, + has_header=has_header, + sheet_name=sheet_name, + skip_lines=skip_lines, + thousands=thousands or None, + decimal=decimal, + comment=comment or None, + sheets=sheets, + ) try: if suffix == ".xlsx": + if excel is None: + excel = open_excel(file_bytes) + sheets = tuple(str(n) for n in excel.sheet_names) + if sheet == "": + sheet_name = sheets[0] + elif sheet in sheets: + sheet_name = sheet + else: + raise UserError(f"シート「{sheet}」がファイルにありません。シートを選び直してください。", field="シート") + kwargs = make_source().read_kwargs() try: - excel = pd.ExcelFile(io.BytesIO(file_bytes)) - sheet_name = str(excel.sheet_names[0]) - df = excel.parse(sheet_name=excel.sheet_names[0], header=header) + df = excel.parse(**kwargs) + if skip_lines > 0 and not df.empty: + preamble = _excel_preamble(excel, sheet_name, skip_lines) except Exception as exc: - raise UserError( - "Excelファイル(.xlsx)を読み込めませんでした。ファイルが壊れていないか、パスワードが掛かっていないかを確認し、" - "Excelで開いて .xlsx で保存し直してください。", - field="入力ファイル", - detail=f"{type(exc).__name__}: {exc}", - ) from exc + raise UserError(_EXCEL_OPEN_ERROR, field="入力ファイル", detail=f"{type(exc).__name__}: {exc}") from exc else: sep = decode_delimiter(delimiter, suffix) + _check_conflicts(sep, thousands, comment) + decimal_warning = _decimal_separator_warning(sep, decimal) + if decimal_warning: + warnings_out.append(decimal_warning) used_encoding, text = detect_encoding(file_bytes) - df = pd.read_csv(io.StringIO(text), sep=sep, header=header, engine="python") + kwargs = make_source().read_kwargs() + df = pd.read_csv(io.StringIO(text), **kwargs) + if skip_lines > 0: + preamble = tuple(split_lines(text)[:skip_lines][:PREAMBLE_LINES]) except UserError: raise except pd.errors.EmptyDataError as exc: - raise UserError("ファイルにデータがありません。内容のあるファイルを選択してください。", field="入力ファイル") from exc + message = "ファイルにデータがありません。内容のあるファイルを選択してください。" + if skip_lines > 0: + message += SKIP_LINES_TOO_LARGE_HINT + raise UserError(message, field="入力ファイル") from exc except (pd.errors.ParserError, ValueError, re.error, csv.Error) as exc: # 列数の不整合、区切り文字が正規表現として不正、など raise UserError( "ファイルを表として読み込めませんでした。「区切り文字」の設定(正規表現として正しいか)や、" - "行ごとの列数が揃っているかを確認してください。", + "行ごとの列数が揃っているかを確認してください。" + SKIP_LINES_HINT, field="区切り文字", detail=f"{type(exc).__name__}: {exc}", ) from exc if df.empty: - raise UserError("ファイルにデータ行がありません。ヘッダ行だけでなく、データ行を含むファイルを選択してください。", field="入力ファイル") + message = "ファイルにデータ行がありません。ヘッダ行だけでなく、データ行を含むファイルを選択してください。" + if skip_lines > 0: + message += SKIP_LINES_TOO_LARGE_HINT + raise UserError(message, field="入力ファイル") if not has_header: df.columns = [f"column_{i}" for i in range(len(df.columns))] - source = SourceInfo( - filename=Path(filename).name, - kind=suffix.lstrip("."), + if suffix != ".xlsx" and len(df.columns) == 1: + hint = _delimiter_warning(text, sep, skip_lines, comment) + if hint: + warnings_out.append(hint) + + # 日時の列の認識(Excel の日付セルは、もともと datetime64 なので対象外で、設定に関係なく日時) + datetime_columns = detect_datetime_columns(df) if parse_dates else () + warnings_out.extend(apply_datetime_columns(df, datetime_columns)) + + return LoadedData( + df=df, encoding=used_encoding, - separator=sep, - has_header=has_header, - sheet_name=sheet_name, + source=dataclasses.replace(make_source(), datetime_columns=datetime_columns), + warnings=warnings_out, + preamble_lines=preamble, + preamble_total=skip_lines if preamble else 0, ) - return LoadedData(df=df, encoding=used_encoding, source=source) def load_dataframe(file_bytes: bytes, filename: str, delimiter: str, has_header: bool) -> pd.DataFrame: @@ -186,7 +480,10 @@ def load_dataframe(file_bytes: bytes, filename: str, delimiter: str, has_header: def column_options(df: pd.DataFrame) -> list[dict]: """列セレクト用の選択肢。値は列番号ベース("__idx__N")で、同名列も区別できる。""" - return [{"value": f"__idx__{i}", "label": f"{col} [{i}]"} for i, col in enumerate(df.columns)] + return [ + {"value": f"__idx__{i}", "label": f"{col} [{i}]", "kind": column_kind(df.dtypes.iloc[i])} + for i, col in enumerate(df.columns) + ] def _format_cells(preview_df: pd.DataFrame) -> list[list[str]]: @@ -200,10 +497,14 @@ def _format_cells(preview_df: pd.DataFrame) -> list[list[str]]: return [[str(v) for v in row] for row in preview_df.itertuples(index=False, name=None)] -def build_preview(df: pd.DataFrame, limit: int = PREVIEW_ROWS) -> dict: +def build_preview(df: pd.DataFrame, limit: int = PREVIEW_ROWS, preamble: tuple[tuple[str, ...], int] | None = None) -> dict: + """プレビュー。preamble は (読み飛ばした行, 行数)。行数が 0 のとき preview["preamble"] は None。""" preview_df = df.head(limit) + lines, total = preamble if preamble else ((), 0) return { + "preamble": {"lines": list(lines[:PREAMBLE_LINES]), "total": int(total)} if total > 0 else None, "columns": [str(c) for c in df.columns], + "columnKinds": [column_kind(dt) for dt in df.dtypes], "rows": _format_cells(preview_df), "totalRows": int(len(df)), "totalColumns": int(len(df.columns)), diff --git a/py/mplgui/runner.py b/py/mplgui/runner.py index 3964cae..6eb16bd 100644 --- a/py/mplgui/runner.py +++ b/py/mplgui/runner.py @@ -106,6 +106,7 @@ def figure_summary(fig) -> list[dict]: "patches": len(ax.patches), "legend": [t.get_text() for t in legend.get_texts()] if legend is not None else [], "xticklabels": [t.get_text() for t in ax.get_xticklabels()], + "xconverter": type(ax.xaxis.get_converter()).__name__ if ax.xaxis.get_converter() is not None else None, } ) return out diff --git a/py/mplgui/settings.py b/py/mplgui/settings.py index 0edda33..d298dd3 100644 --- a/py/mplgui/settings.py +++ b/py/mplgui/settings.py @@ -1,4 +1,4 @@ -"""設定オブジェクト(JSON, camelCase, version=2)の既定値・検証・型付きデータクラス。 +"""設定オブジェクト(JSON, camelCase, version=3)の既定値・検証・型付きデータクラス。 JS 側の js/defaults.js と default_settings() は同一でなければならない(単体テストで比較する)。 このモジュールは js / pyodide / pyscript に依存しない。 @@ -7,6 +7,8 @@ import copy import math +import re +from datetime import datetime from dataclasses import dataclass, field from typing import Any @@ -14,7 +16,7 @@ from .errors import UserError -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 PLOT_TYPES = ("line", "scatter", "bar") LINE_STYLES = ("solid", "dashed", "dashdot", "dotted") @@ -43,6 +45,12 @@ "none", ) DEFAULT_SCATTER_MARKER_SIZE = 24.0 +THOUSANDS_CHOICES = ("", ",", ".", " ", "'") +DECIMAL_CHOICES = (".", ",") +MAX_SKIP_LINES = 1_000_000 +MAX_FILES = 10 +MAX_DATE_FORMAT_LENGTH = 64 +_FILE_ID_RE = re.compile(r"d[0-9]{1,4}") _DEFAULT_SERIES = { "id": "s1", @@ -54,6 +62,7 @@ "markerSize": None, "label": "", "secondaryAxis": False, + "source": "", } @@ -61,7 +70,16 @@ def default_settings() -> dict: """既定の設定(JSON 互換の dict)。呼ぶたびに新しい dict を返す。""" return { "version": SCHEMA_VERSION, - "load": {"delimiter": "", "hasHeader": True}, + "load": { + "delimiter": "", + "hasHeader": True, + "skipLines": 0, + "thousands": "", + "decimal": ".", + "comment": "", + "parseDates": True, + "files": [], + }, "plot": { "type": "line", "skipRows": 0, @@ -76,7 +94,7 @@ def default_settings() -> dict: "margins": {"left": None, "right": None, "bottom": None, "top": None}, }, "axes": { - "x": {"label": "", "scale": "linear", "min": None, "max": None}, + "x": {"label": "", "scale": "linear", "min": None, "max": None, "dateFormat": ""}, "y": {"label": "", "scale": "linear", "min": None, "max": None}, "y2": {"label": "", "scale": "linear", "min": None, "max": None}, }, @@ -88,10 +106,23 @@ def default_settings() -> dict: # ---------------------------------------------------------------- dataclasses +@dataclass(frozen=True) +class FileEntry: + id: str + name: str = "" + sheet: str = "" # "" = 先頭シート + + @dataclass(frozen=True) class LoadSettings: delimiter: str = "" has_header: bool = True + skip_lines: int = 0 + thousands: str = "" + decimal: str = "." + comment: str = "" + parse_dates: bool = True + files: tuple[FileEntry, ...] = () @dataclass(frozen=True) @@ -100,6 +131,7 @@ class AxisSettings: scale: str = "linear" min: float | None = None max: float | None = None + date_format: str = "" # X 軸だけ。"" = 自動 @dataclass(frozen=True) @@ -162,6 +194,7 @@ class SeriesSettings: marker_size: float | None = None # None = プロット種別ごとの自動値 label: str = "" secondary_axis: bool = False + source: str = "" # データ元のファイル id("" = load.files の先頭) def effective_marker_size(self, plot_type: str) -> float: """自動(None)は line=0 / それ以外=24。scatter で 0 以下なら 24 に補正する。""" @@ -294,19 +327,29 @@ def migrate_settings(raw: dict) -> dict: バージョンが無いものは現在のバージョンとして扱う。対応していないバージョンは UserError。 - 1 → 2: 保存 DPI を追加する。v1 は常に 120 dpi で保存していたので、同じ出力になるよう 120 にする。 図の単位・欧文フォント・カラーパレット・SVG の文字は、既定値で補われる。 + - 2 → 3: データ取り込みの項目(読み飛ばす行数・桁区切り・小数点・コメント記号・複数ファイル・ + 系列のデータ元・X 軸の日時の書式)を追加する。日時の自動認識(load.parseDates)は、v2 までの + 出力を保つため false にする(v3 で新しく作る設定の既定値は true)。他の項目は既定値で補われる。 """ version = raw.get("version", SCHEMA_VERSION) - if isinstance(version, bool) or version not in (1, SCHEMA_VERSION): + if isinstance(version, bool) or version not in (1, 2, SCHEMA_VERSION): raise UserError(f"設定のバージョン({version})に対応していません。ページを再読み込みしてください。", field="version") out = copy.deepcopy(raw) if version == 1: - out["version"] = SCHEMA_VERSION save = out.get("save") if not isinstance(save, dict): save = {} if "dpi" not in save: save["dpi"] = 120 out["save"] = save + if version in (1, 2): + out["version"] = SCHEMA_VERSION + load = out.get("load") + if not isinstance(load, dict): + load = {} + if "parseDates" not in load: + load["parseDates"] = False + out["load"] = load return out @@ -317,18 +360,95 @@ def parse_load_settings(raw: dict | None) -> LoadSettings: """読み込み設定だけを検証する(描画設定の不備でファイル読込を止めないため)。""" raw = migrate_settings(raw if isinstance(raw, dict) else {}) load = _merge(default_settings()["load"], raw.get("load")) + skip_lines = _integer(load["skipLines"], "ヘッダより前に読み飛ばす行数", default=0, minimum=0) + if skip_lines > MAX_SKIP_LINES: + raise UserError(f"「ヘッダより前に読み飛ばす行数」は{MAX_SKIP_LINES}以下の整数で入力してください。", field="ヘッダより前に読み飛ばす行数") + thousands = _choice(load["thousands"], "桁区切り", THOUSANDS_CHOICES) + decimal = _choice(load["decimal"], "小数点", DECIMAL_CHOICES) + if thousands and thousands == decimal: + raise UserError("「桁区切り」と「小数点」に同じ記号は使えません。", field="桁区切り") + comment = _parse_comment(load["comment"], thousands, decimal) return LoadSettings( delimiter=_string(load["delimiter"], "区切り文字"), has_header=_boolean(load["hasHeader"], "先頭行をヘッダとして扱う"), + skip_lines=skip_lines, + thousands=thousands, + decimal=decimal, + comment=comment, + parse_dates=_boolean(load["parseDates"], "日時の列を自動で認識する"), + files=_parse_files(load["files"]), ) +def _parse_comment(value: Any, thousands: str, decimal: str) -> str: + label = "コメント記号" + if value is None: + return "" + if not isinstance(value, str): + raise UserError(f"「{label}」は文字列で指定してください。", field=label) + if value == "": + return "" + if len(value) != 1: + raise UserError(f"「{label}」は1文字で指定してください(例: #)。", field=label) + if value == '"' or value.isspace(): + raise UserError(f"「{label}」に \" や空白は使えません。", field=label) + if value in (thousands, decimal): + raise UserError(f"「{label}」に、桁区切り・小数点と同じ記号は使えません。", field=label) + return value + + +def _parse_files(raw_files: Any) -> tuple[FileEntry, ...]: + label = "読み込むファイル" + if not isinstance(raw_files, list): + raise UserError(f"「{label}」の値が不正です。", field=label) + if len(raw_files) > MAX_FILES: + raise UserError(f"読み込めるファイルは{MAX_FILES}個までです。", field=label) + out = [] + seen: set[str] = set() + for item in raw_files: + if not isinstance(item, dict): + raise UserError(f"「{label}」の値が不正です。", field=label) + fid = item.get("id") + if not isinstance(fid, str) or not _FILE_ID_RE.fullmatch(fid): + raise UserError(f"「{label}」のIDが不正です。", field=label) + if fid in seen: + raise UserError(f"「{label}」のIDが重複しています。", field=label) + seen.add(fid) + out.append( + FileEntry( + id=fid, + name=_string(item.get("name", ""), "ファイル名", owner="読み込むファイルの"), + sheet=_string(item.get("sheet", ""), "シート", owner="読み込むファイルの"), + ) + ) + return tuple(out) + + def _parse_axis(raw: dict, key: str, name: str) -> AxisSettings: axis = raw[key] lo = _number(axis["min"], f"{name}軸の最小値", default=None) hi = _number(axis["max"], f"{name}軸の最大値", default=None) scale = _choice(axis["scale"], f"{name}軸スケール", SCALES) - return AxisSettings(label=_string(axis["label"], f"{name}軸ラベル").strip(), scale=scale, min=lo, max=hi) + date_format = _date_format(axis["dateFormat"]) if key == "x" else "" + return AxisSettings(label=_string(axis["label"], f"{name}軸ラベル").strip(), scale=scale, min=lo, max=hi, date_format=date_format) + + +def _date_format(value: Any) -> str: + label = "X軸の日時の書式" + if value is None: + return "" + bad = UserError(f"「{label}」が正しくありません。%Y/%m/%d のように、% で始まる指定を含めてください。", field=label) + if not isinstance(value, str) or len(value) > MAX_DATE_FORMAT_LENGTH: + raise bad + if value == "": + return "" + if "%" not in value: + raise bad + try: + datetime(2024, 1, 31, 13, 45, 30).strftime(value) + except (ValueError, TypeError, OverflowError): + raise bad from None + return value def _check_axis_range(axis: AxisSettings, name: str) -> None: @@ -367,6 +487,7 @@ def _parse_series(raw_series: Any) -> tuple[SeriesSettings, ...]: marker_size=marker, label=_string(s["label"], "凡例名", owner=owner).strip(), secondary_axis=_boolean(s["secondaryAxis"], "第2軸を使用", owner=owner), + source=_string(s["source"], "データ元", owner=owner), ) ) return tuple(out) @@ -433,6 +554,10 @@ def parse_settings(raw: dict | None) -> Settings: y2=_parse_axis(ax, "y2", "第2Y"), ) series = _parse_series(raw.get("series", d["series"])) + file_ids = {f.id for f in load.files} + for i, s in enumerate(series): + if s.source and s.source not in file_ids: + raise UserError(f"系列{i + 1}のデータ元が見つかりません。データ元を選び直してください。", field=f"系列{i + 1}のデータ元") _check_axis_range(axes.x, "X") _check_axis_range(axes.y, "Y") if any(s.secondary_axis for s in series): diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 576b366..21dbc8a 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -304,3 +304,125 @@ def wait_code_contains(page, text, timeout=60_000): page.wait_for_function( "(t) => document.getElementById('customPyCode').value.includes(t)", arg=text, timeout=timeout ) + + +# --- データ読み込みの設定(シート・ヘッダより前の行・桁区切り・小数点・コメント記号) --- +def change_load_option(page, action): + """action() で読込設定を変え、読み直し(data-load-count が増える)と再描画が済むまで待つ。""" + before = load_count(page) + action() + wait_load_count(page, before + 1) + wait_settled(page) + + +def set_load_options(page, *, delimiter=None, skip_lines=None, thousands=None, decimal=None, comment=None): + """ファイルを選ぶ前に、読込設定を入れておく(ファイルが無いので読み直しは起こらない)。""" + if delimiter is not None: + page.fill("#delimiter", delimiter) + if skip_lines is not None: + page.fill("#skipLines", str(skip_lines)) + if thousands is not None: + page.select_option("#thousands", thousands) + if decimal is not None: + page.select_option("#decimal", decimal) + if comment is not None: + page.fill("#commentChar", comment) + + +def sheet_options(page) -> list[str]: + return page.evaluate("[...document.querySelectorAll('#sheetSelect option')].map(o => o.value)") + + +def code_is_stale(page) -> bool: + """コードタブの「最新でない」表示(#customPyCode の data-stale と #codeStaleNote)。""" + attr = page.evaluate("document.getElementById('customPyCode').dataset.stale || ''") == "true" + note = page.evaluate("!document.getElementById('codeStaleNote').hidden") + assert attr == note, "data-stale と #codeStaleNote の表示が食い違っている" + return attr + + +# --- ドロップ・貼り付け(D1) --- +DRAG_JS = """([type, selector, files, text]) => { + const dt = new DataTransfer(); + for (const f of files) dt.items.add(new File([new Uint8Array(f.bytes)], f.name)); + if (text !== null) dt.setData('text/plain', text); + const target = document.querySelector(selector); + const event = new DragEvent(type, {dataTransfer: dt, bubbles: true, cancelable: true}); + target.dispatchEvent(event); + return event.defaultPrevented; +}""" + + +def drag_event(page, type_, selector="body", files=(), text=None) -> bool: + """ファイル(名前, バイト列)や文字列を持つ DragEvent を要素に送る。defaultPrevented を返す。""" + payload = [{"name": name, "bytes": list(data)} for name, data in files] + return page.evaluate(DRAG_JS, [type_, selector, payload, text]) + + +def drop_files(page, files, selector="body"): + """ドロップの一連のイベント(dragenter → dragover → drop)を送る。""" + drag_event(page, "dragenter", selector, files) + drag_event(page, "dragover", selector, files) + return drag_event(page, "drop", selector, files) + + +def drop_overlay_visible(page) -> bool: + return page.evaluate("!document.getElementById('dropOverlay').hidden") + + +PASTE_JS = """([selector, text]) => { + const target = selector ? document.querySelector(selector) : document.body; + if (selector) target.focus(); + const dt = new DataTransfer(); + dt.setData('text/plain', text); + const event = new ClipboardEvent('paste', {clipboardData: dt, bubbles: true, cancelable: true}); + target.dispatchEvent(event); + return event.defaultPrevented; +}""" + + +def paste_text(page, text, selector="#pasteArea") -> bool: + """ClipboardEvent('paste') を送る(selector=None は body)。defaultPrevented を返す。""" + return page.evaluate(PASTE_JS, [selector, text]) + + +EXCEL_TABLE = "時間\t電圧\t電流\r\n0\t0\t1.5\r\n1\t0.5\t1.4\r\n2\t0.9\t1.1\r\n3\t1.0\t0.8\r\n" + + +# --- 複数ファイル(D6) --- +def add_files(page, paths, selector="#addFileInput", timeout=120_000): + """ファイルを追加(または selector で置き換え)し、読み込みと描画が済むまで待つ。""" + before = load_count(page) + page.set_input_files(selector, [str(p) for p in paths]) + wait_load_count(page, before + 1, timeout=timeout) + wait_settled(page, timeout=timeout) + + +def file_row_ids(page) -> list[str]: + """一覧(#fileList)の行の id(file-d1 など)。""" + return page.evaluate("[...document.querySelectorAll('#fileList .file-row')].map(e => e.id)") + + +def file_row_states(page) -> dict[str, str]: + return page.evaluate("Object.fromEntries([...document.querySelectorAll('#fileList .file-row')].map(e => [e.id, e.dataset.state]))") + + +def settings_files(page) -> list[dict]: + """画面のファイル一覧に対応する、データ元セレクト(#previewSource)の選択肢の値。""" + return page.evaluate("[...document.querySelectorAll('#previewSource option')].map(o => o.value)") + + +def add_series_with_source(page, source, y="__idx__1", series_id="s2"): + """系列を追加し、データ元と Y 列を選ぶ。""" + page.click("#addSeriesBtn") + page.select_option(f"#series-{series_id}-source", source) + page.select_option(f"#series-{series_id}-y", y) + wait_settled(page) + + +def remove_file(page, file_id): + """ファイルを取り除く。一覧の削除ボタンは2ファイル以上のときだけ出るので、最後の1つは bridge.js の removeFile で取り除く。""" + if page.locator(f"#file-{file_id}-remove").count() > 0: + page.click(f"#file-{file_id}-remove") + else: + page.evaluate("(id) => import(new URL('js/bridge.js', document.baseURI)).then((m) => m.removeFile(id))", file_id) diff --git a/tests/e2e/test_codegen_consistency.py b/tests/e2e/test_codegen_consistency.py index f93122f..90047b6 100644 --- a/tests/e2e/test_codegen_consistency.py +++ b/tests/e2e/test_codegen_consistency.py @@ -12,9 +12,13 @@ import pytest from helpers import ( + add_files, + add_series_with_source, image_summary, load_fixture, open_code_tab, + paste_text, + wait_data_ready, wait_app_ready, wait_code_contains, wait_code_generated, @@ -74,10 +78,75 @@ def _configure_scatter_arimo_paper2_pdf(page): page.select_option("#saveFormat", "pdf") +def _pre_preamble(page): + page.fill("#skipLines", "3") # ファイルを選ぶ前に設定する(先頭の説明の行を読み飛ばす) + + +def _pre_european(page): + page.fill("#delimiter", ";") + page.select_option("#thousands", ".") + page.select_option("#decimal", ",") + + +def _configure_xy_1(page): + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__1") + + +def _configure_datetime_line(page): + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__3") + + +def _configure_xlsx_datetime(page): + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__1") + + +def _configure_second_sheet(page): + page.select_option("#sheetSelect", "二枚目") + page.wait_for_function("document.documentElement.dataset.loadCount === '2'") + wait_settled(page) + _configure_xy_1(page) + + +def _configure_two_csv(page): + page.select_option("#series-s1-y", "__idx__1") + add_series_with_source(page, "d2", "__idx__2") + + +def _configure_csv_and_second_sheet(page): + page.select_option("#file-d2-sheet", "二枚目") + page.wait_for_function("document.documentElement.dataset.loadCount === '3'") + wait_settled(page) + page.select_option("#series-s1-y", "__idx__1") + add_series_with_source(page, "d2", "__idx__2") + + +# 読込の設定を変えた場合: 生成スクリプトの読込部が、GUI の読み方と同じ図を作る +PRE_STEPS = {"preamble_csv": _pre_preamble, "european_csv": _pre_european} + +PASTED_FIXTURE = "tab.txt" # 貼り付けた表の代わりに、タブ区切りのフィクスチャの中身を貼り付ける + + +def _paste_fixture(page, fixtures_dir): + text = (fixtures_dir / PASTED_FIXTURE).read_text(encoding="utf-8").replace("\n", "\r\n") # Excel のコピーは CRLF + paste_text(page, text) + wait_data_ready(page) + + CASES = { + "pasted_tsv": (None, _configure_xy_1), "line": ("growth.csv", _configure_line), "scatter": ("utf8.csv", _configure_scatter), "bar": ("categories.csv", _configure_bar), + "xlsx_second_sheet": ("multi_sheet.xlsx", _configure_second_sheet), + "datetime_axis": ("datetime.csv", _configure_datetime_line), + "xlsx_datetime": ("datetime.xlsx", _configure_xlsx_datetime), + "two_csv_files": (("utf8.csv", "growth.csv"), _configure_two_csv), + "csv_and_xlsx_second_sheet": (("utf8.csv", "multi_sheet.xlsx"), _configure_csv_and_second_sheet), + "preamble_csv": ("preamble.csv", _configure_xy_1), + "european_csv": ("european.csv", _configure_xy_1), "scatter_paper1": ("utf8.csv", _configure_scatter_paper1), "scatter_arimo_paper2_pdf": ("utf8.csv", _configure_scatter_arimo_paper2_pdf), } @@ -102,7 +171,16 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ fixture, configure = CASES[case] page.goto(app_url) wait_app_ready(page) - load_fixture(page, fixtures_dir / fixture) + if case in PRE_STEPS: + PRE_STEPS[case](page) + if fixture is None: + _paste_fixture(page, fixtures_dir) + else: + names = fixture if isinstance(fixture, tuple) else (fixture,) + load_fixture(page, fixtures_dir / names[0]) + wait_settled(page) + for extra in names[1:]: + add_files(page, [fixtures_dir / extra]) wait_settled(page) configure(page) wait_settled(page) @@ -117,7 +195,14 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ page.click("#downloadCodeBtn") script = tmp_path / dl.value.suggested_filename dl.value.save_as(script) - shutil.copy(fixtures_dir / fixture, tmp_path / fixture) # データは元のファイル名でスクリプトの隣に置く + if fixture is None: + with page.expect_download() as pasted_dl: + page.click("#savePastedBtn") # 貼り付けたデータは、保存したファイルをスクリプトの隣に置く + pasted_dl.value.save_as(tmp_path / pasted_dl.value.suggested_filename) + assert pasted_dl.value.suggested_filename == "pasted_data.tsv" + else: + for name in fixture if isinstance(fixture, tuple) else (fixture,): + shutil.copy(fixtures_dir / name, tmp_path / name) # データは元のファイル名でスクリプトの隣に置く local, stderr = _run_in_cpython(script, tmp_path) assert len(local) == len(browser) @@ -127,6 +212,29 @@ def test_downloaded_script_matches_browser_figure(case, page, app_url, fixtures_ assert l_ax[key] == b_ax[key], key assert l_ax["xlim"] == pytest.approx(b_ax["xlim"], rel=1e-6) assert l_ax["ylim"] == pytest.approx(b_ax["ylim"], rel=1e-6) + if case in ("two_csv_files", "csv_and_xlsx_second_sheet"): + text = script.read_text(encoding="utf-8") + assert browser[0]["lines"] == 2 and "DATA_FILE_1" in text and "DATA_FILE_2" in text and "df2.iloc[:, 2]" in text + assert ('sheet_name="二枚目",' in text) == (case == "csv_and_xlsx_second_sheet") + if case in ("xlsx_second_sheet", "preamble_csv", "european_csv"): + assert len(browser) == 1 and browser[0]["lines"] == 1 + text = script.read_text(encoding="utf-8") + expected = { + "xlsx_second_sheet": 'sheet_name="二枚目",', + "preamble_csv": "skiprows=3,", + "european_csv": 'thousands=".",', + }[case] + assert expected in text + if case == "european_csv": + assert 'decimal=",",' in text and 'sep=";",' in text + assert browser[0]["ylim"][1] > 1000 # 1.234,5 が数値として読めている + if case == "pasted_tsv": + assert len(browser) == 1 and browser[0]["lines"] == 1 + assert "# 貼り付けたデータ:" in script.read_text(encoding="utf-8") + if case in ("datetime_axis", "xlsx_datetime"): + assert "Date" in browser[0]["xconverter"] and local[0]["xconverter"] == browser[0]["xconverter"] + assert local[0]["xticklabels"] == browser[0]["xticklabels"] and any(browser[0]["xticklabels"]) + assert ("to_datetime" in script.read_text(encoding="utf-8")) == (case == "datetime_axis") if case == "bar": assert local[0]["xticklabels"] == browser[0]["xticklabels"] assert "品目A" in browser[0]["xticklabels"][0] diff --git a/tests/e2e/test_datetime.py b/tests/e2e/test_datetime.py new file mode 100644 index 0000000..087e0cd --- /dev/null +++ b/tests/e2e/test_datetime.py @@ -0,0 +1,182 @@ +"""日時の列(D5): 認識、日時の軸、表示書式、自動認識の切り替え、棒グラフ、Excel の日時セル、エラー。""" +import re + +import pytest + +from helpers import ( + change_load_option, + code_text, + image_summary, + load_fixture, + open_code_tab, + plot_src, + status_kind, + status_text, + wait_app_ready, + wait_code_contains, + wait_code_generated, + wait_plot_changed, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def start(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + return page + + +def labels(page): + return [t for t in image_summary(page)[0]["xticklabels"] if t] + + +def pick(page, x, y): + page.select_option("#series-s1-x", x) + page.select_option("#series-s1-y", y) + wait_settled(page) + + +def choose_format(page, value): + before = plot_src(page) + page.select_option("#xDateFormat", value) + wait_plot_changed(page, before) + wait_settled(page) + + +def test_datetime_csv_draws_a_date_axis_with_warning_and_tag(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.csv") + wait_settled(page) + assert page.is_hidden("#xDateFormatGroup") # X が行番号のあいだは出ない + pick(page, "__idx__0", "__idx__3") + assert status_kind(page) in ("ok", "warning") + summary = image_summary(page)[0] + assert "Date" in summary["xconverter"] and summary["lines"] == 1 + assert any(re.search(r"\d", t) for t in labels(page)) + assert "日時として読めない値が1件" in status_text(page) and "測定エラー" in status_text(page) + assert page.is_visible("#xDateFormatGroup") + assert page.locator("#dataArea th .col-kind-tag").count() == 1 + assert page.locator("#dataArea th .col-kind-tag").first.text_content() == "日時" + open_code_tab(page) + text = code_text(page) + assert 'df.isetitem(0, pd.to_datetime(df.iloc[:, 0], format="%Y/%m/%d %H:%M", errors="coerce"))' in text + assert "mdates.ConciseDateFormatter(date_locator)" in text and "set_xscale" not in text + # ID(A-001)は文字列、通番は数値のまま + options = page.inner_text("#series-s1-x") + assert "ID [1]" in options and "日時 [0]" in options + + +def test_date_format_presets_and_custom(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.csv") + wait_settled(page) + pick(page, "__idx__0", "__idx__3") + choose_format(page, "%m/%d") + assert labels(page) and all(re.fullmatch(r"\d\d/\d\d", t) for t in labels(page)) + wait_code_generated(page) + wait_code_contains(page, 'mdates.DateFormatter("%m/%d")') + assert page.is_hidden("#xDateFormatCustom") + before = plot_src(page) + page.select_option("#xDateFormat", "custom") + assert page.is_visible("#xDateFormatCustom") + page.fill("#xDateFormatCustom", "%m月%d日 %H時") + wait_plot_changed(page, before) + wait_settled(page) + assert all("月" in t and "時" in t for t in labels(page)) + wait_code_contains(page, 'mdates.DateFormatter("%m月%d日 %H時")') + choose_format(page, "") # 自動に戻す + wait_code_contains(page, "ConciseDateFormatter") + + +def test_invalid_date_format_is_a_japanese_error(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.csv") + wait_settled(page) + pick(page, "__idx__0", "__idx__3") + page.select_option("#xDateFormat", "custom") + page.fill("#xDateFormatCustom", "年月日") # % が無い + page.wait_for_function("document.getElementById('status').dataset.kind === 'error'") + assert "X軸の日時の書式" in status_text(page) + + +def test_x_range_with_datetime_is_an_error_and_recovers(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.csv") + wait_settled(page) + pick(page, "__idx__0", "__idx__3") + page.fill("#xMin", "5") + page.wait_for_function("document.getElementById('status').dataset.kind === 'error'") + assert "X軸の最小値・最大値は指定できません" in status_text(page) + before = plot_src(page) + page.fill("#xMin", "") + wait_settled(page) + assert status_kind(page) != "error" + + +def test_unchecking_parse_dates_makes_the_column_text(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.csv") + wait_settled(page) + pick(page, "__idx__0", "__idx__3") + assert page.is_visible("#xDateFormatGroup") and page.is_checked("#parseDates") + change_load_option(page, lambda: page.uncheck("#parseDates")) + assert page.is_hidden("#xDateFormatGroup") + assert page.locator("#dataArea th .col-kind-tag").count() == 0 + summary = image_summary(page)[0] + assert "Date" not in (summary["xconverter"] or "") + open_code_tab(page) + assert "to_datetime" not in code_text(page) + assert "set_xscale" not in code_text(page) and "MaxNLocator" in code_text(page) # 文字列の X は目盛を間引く(150 種) + change_load_option(page, lambda: page.check("#parseDates")) + assert page.is_visible("#xDateFormatGroup") + + +def test_bar_chart_with_japanese_dates(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "dates_ja.csv") + wait_settled(page) + page.select_option("#plotType", "bar") + page.select_option("#xColumn", "__idx__0") + page.select_option("#series-s1-y", "__idx__1") + wait_settled(page) + assert page.is_visible("#xDateFormatGroup") + assert labels(page)[0] == "2026-01-05" and len(labels(page)) == 8 + choose_format(page, "%m/%d") + assert labels(page)[0] == "01/05" + wait_code_contains(page, '.dt.strftime("%m/%d")') + + +def test_excel_datetime_cells_are_dates(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.xlsx") + wait_settled(page) + pick(page, "__idx__0", "__idx__1") + assert "Date" in image_summary(page)[0]["xconverter"] + open_code_tab(page) + assert "to_datetime" not in code_text(page) and "ConciseDateFormatter" in code_text(page) + change_load_option(page, lambda: page.uncheck("#parseDates")) # Excel の日時セルは、設定に関係なく日時 + assert "Date" in image_summary(page)[0]["xconverter"] + + +def test_mixed_datetime_and_number_x_is_an_error(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime.csv") + wait_settled(page) + pick(page, "__idx__0", "__idx__3") + page.click("#addSeriesBtn") + page.select_option("#series-s2-x", "__idx__2") + page.select_option("#series-s2-y", "__idx__4") + page.wait_for_function("document.getElementById('status').dataset.kind === 'error'") + assert "同じ図に描くことはできません" in status_text(page) + + +def test_iso_with_timezone_fixture_warns_about_mixed_offsets(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "datetime_iso_tz.csv") + wait_settled(page) + pick(page, "__idx__1", "__idx__2") + assert "UTC の時刻にそろえました" in status_text(page) + assert "Date" in image_summary(page)[0]["xconverter"] diff --git a/tests/e2e/test_drop_paste.py b/tests/e2e/test_drop_paste.py new file mode 100644 index 0000000..75b8e35 --- /dev/null +++ b/tests/e2e/test_drop_paste.py @@ -0,0 +1,306 @@ +"""D1: ファイルのドロップ(ページ全体)、表の貼り付け、貼り付けたデータの保存、.tsv。""" +import pytest + +from helpers import ( + code_text, + drag_event, + drop_files, + drop_overlay_visible, + enter_edit_mode, + load_count, + open_code_tab, + paste_text, + plot_src, + run_edited_code, + status_kind, + status_text, + status_warnings, + wait_app_ready, + wait_code_generated, + wait_data_ready, + wait_load_count, + wait_settled, + EXCEL_TABLE, +) + +pytestmark = pytest.mark.e2e + +PASTE_WHILE_LOADED = "表を貼り付けて読み込むときは、「表を貼り付け」欄に貼り付けてください(読み込み済みのデータは置き換わります)。" +NOT_A_TABLE = "貼り付けた内容が表ではありません。Excel などで表の範囲をコピーしてから貼り付けてください。" + + +@pytest.fixture +def start(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + return page + + +def fixture_file(fixtures_dir, name): + return (name, (fixtures_dir / name).read_bytes()) + + +def drop_and_wait(page, files, selector="body"): + before = load_count(page) + drop_files(page, files, selector) + wait_load_count(page, before + 1) + wait_settled(page) + + +def test_drop_csv_loads_and_plots(start, fixtures_dir): + page = start + assert not drop_overlay_visible(page) + assert page.get_attribute("#dropOverlay", "aria-hidden") == "true" + drop_and_wait(page, [fixture_file(fixtures_dir, "utf8.csv")]) + assert page.get_attribute("html", "data-data-state") == "ready" + assert "utf8.csv" in page.inner_text("#fileNameLabel") + assert plot_src(page).startswith("data:image/png") + assert not drop_overlay_visible(page) + + +def test_overlay_shows_during_dragover_and_hides_after_drop(start, fixtures_dir): + page = start + files = [fixture_file(fixtures_dir, "utf8.csv")] + drag_event(page, "dragenter", "body", files) + prevented = drag_event(page, "dragover", "#plotArea", files) + assert prevented # ブラウザがファイルを開かない + assert drop_overlay_visible(page) + assert page.get_attribute("#dropOverlay", "aria-hidden") == "false" + assert "ここにドロップして読み込む" in page.inner_text("#dropOverlay") + # 子要素への出入りでちらつかない(enter 2 回、leave 1 回では出たまま) + drag_event(page, "dragenter", "#title", files) + drag_event(page, "dragleave", "#plotArea", files) + assert drop_overlay_visible(page) + before = load_count(page) + assert drag_event(page, "drop", "#plotArea", files) + assert not drop_overlay_visible(page) + wait_load_count(page, before + 1) + + +def test_overlay_hides_on_leave_and_escape(start, fixtures_dir): + page = start + files = [fixture_file(fixtures_dir, "utf8.csv")] + drag_event(page, "dragenter", "body", files) + assert drop_overlay_visible(page) + drag_event(page, "dragleave", "body", files) + assert not drop_overlay_visible(page) + drag_event(page, "dragenter", "body", files) + assert drop_overlay_visible(page) + page.keyboard.press("Escape") + assert not drop_overlay_visible(page) + + +def test_text_drag_does_not_show_overlay(start): + page = start + drag_event(page, "dragenter", "body", text="hello") + prevented = drag_event(page, "dragover", "body", text="hello") + assert not drop_overlay_visible(page) + assert not prevented # 文字列のドラッグには手を出さない + drag_event(page, "drop", "#title", text="hello") + assert page.get_attribute("html", "data-data-state") == "none" + + +def test_drop_two_files_loads_both(start, fixtures_dir): + page = start + drop_and_wait(page, [fixture_file(fixtures_dir, "utf8.csv"), fixture_file(fixtures_dir, "growth.csv")]) + assert "utf8.csv ほか 1 件" in page.inner_text("#fileNameLabel") + assert page.locator("#fileList .file-row").count() == 2 + assert status_warnings(page) == [] + + +def test_drop_on_code_textarea_loads_instead_of_navigating(start, fixtures_dir): + page = start + url = page.url + drop_and_wait(page, [fixture_file(fixtures_dir, "utf8.csv")], "#customPyCode") + assert page.url == url and page.get_attribute("html", "data-data-state") == "ready" + + +def test_drop_unsupported_extension_shows_loader_error(start): + page = start + drop_files(page, [("memo.json", b"{}")]) + page.wait_for_function("document.documentElement.dataset.dataState === 'error'") + assert status_kind(page) == "error" and "対応していない拡張子" in status_text(page) + + +def test_drop_without_files_warns(start): + page = start + page.evaluate( + """() => { + const dt = new DataTransfer(); + dt.items.add(new File([''], 'x.csv')); + dt.items.clear(); + const e = new DragEvent('drop', {dataTransfer: dt, bubbles: true, cancelable: true}); + Object.defineProperty(dt, 'types', {value: ['Files']}); + document.body.dispatchEvent(e); + }""" + ) + assert status_kind(page) == "warning" and "読み込めるファイルが見つかりません" in status_text(page) + + +def test_tsv_file_loads_with_tab_separator(start): + page = start + drop_and_wait(page, [("data.tsv", "x\ty\n1\t2\n2\t4\n3\t9\n".encode())]) + assert page.inner_text("#series-s1-y").count("[") >= 2 + assert page.get_attribute("html", "data-data-state") == "ready" + page.select_option("#series-s1-y", "__idx__1") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + text = code_text(page) + assert 'sep="\\t"' in text and "TSV(.tsv)" in text and "貼り付けたデータ" not in text + + +def test_file_input_accepts_tsv(start): + page = start + assert ".tsv" in page.get_attribute("#fileInput", "accept") + assert ".tsv" in page.inner_text("label[for=fileInput]") + + +def paste_and_wait(page, text, selector="#pasteArea"): + before = load_count(page) + paste_text(page, text, selector) + wait_load_count(page, before + 1) + wait_settled(page) + + +def test_paste_excel_table_loads_and_plots(start): + page = start + assert page.is_hidden("#savePastedBtn") + assert paste_text(page, EXCEL_TABLE) is True # 貼り付けの既定動作は止める + wait_data_ready(page) + wait_settled(page) + assert page.input_value("#pasteArea") == "" + assert "貼り付けたデータ" in page.inner_text("#fileNameLabel") + assert "時間 [0]" in page.inner_text("#series-s1-y") + assert plot_src(page).startswith("data:image/png") + assert page.is_visible("#savePastedBtn") + + +def test_paste_replaces_current_data(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "utf8.csv")) + wait_data_ready(page) + wait_settled(page) + paste_and_wait(page, "a\tb\n1\t2\n3\t4\n") + assert "a [0]" in page.inner_text("#series-s1-y") + + +def test_input_event_with_value_is_treated_as_paste(start): + page = start + before = load_count(page) + page.evaluate( + """(t) => { const a = document.getElementById('pasteArea'); a.value = t; a.dispatchEvent(new Event('input', {bubbles: true})); }""", + EXCEL_TABLE, + ) + wait_load_count(page, before + 1) + assert page.input_value("#pasteArea") == "" + + +def test_document_level_paste_with_body_focus(start): + page = start + page.evaluate("document.activeElement && document.activeElement.blur()") + paste_and_wait(page, EXCEL_TABLE, selector=None) + assert page.get_attribute("html", "data-data-state") == "ready" + assert "貼り付けたデータ" in page.inner_text("#fileNameLabel") + + +def test_paste_into_title_is_a_normal_paste(start): + page = start + assert paste_text(page, EXCEL_TABLE, "#title") is False + page.wait_for_timeout(500) + assert page.get_attribute("html", "data-data-state") == "none" + assert load_count(page) == 0 + + +def test_non_table_text_warns_and_loads_nothing(start): + page = start + paste_text(page, "ただの一行の文章です") + assert status_kind(page) == "warning" and status_text(page) == NOT_A_TABLE + assert page.get_attribute("html", "data-data-state") == "none" + paste_text(page, " \n \n") + assert status_text(page) == NOT_A_TABLE + + +def test_two_lines_without_tabs_count_as_a_table(start): + page = start + paste_and_wait(page, "a,b\n1,2\n3,4\n") + assert page.get_attribute("html", "data-data-state") == "ready" + + +def test_code_tab_shows_pasted_file_and_comment(start): + page = start + paste_and_wait(page, EXCEL_TABLE) + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__1") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + text = code_text(page) + assert 'DATA_FILE = "pasted_data.tsv"' in text + assert "# 貼り付けたデータ: 「データ読み込み」の「貼り付けたデータを保存」で pasted_data.tsv を保存し、このスクリプトと同じフォルダに置いてください。" in text + assert 'sep="\\t"' in text + + +def test_save_pasted_downloads_identical_bytes(start): + page = start + paste_and_wait(page, EXCEL_TABLE) + with page.expect_download() as dl: + page.click("#savePastedBtn") + assert dl.value.suggested_filename == "pasted_data.tsv" + data = open(dl.value.path(), "rb").read() + assert data == EXCEL_TABLE.replace("\r\n", "\n").encode("utf-8") # 改行は LF にそろえて読み込む + + +def test_save_button_hides_when_a_file_replaces_pasted_data(start, fixtures_dir): + page = start + paste_and_wait(page, EXCEL_TABLE) + assert page.is_visible("#savePastedBtn") + page.set_input_files("#fileInput", str(fixtures_dir / "utf8.csv")) + wait_settled(page) + assert page.is_hidden("#savePastedBtn") + assert "貼り付けたデータ" not in page.inner_text("#fileNameLabel") + + +def test_edit_mode_code_can_read_pasted_file(start): + page = start + paste_and_wait(page, EXCEL_TABLE) + enter_edit_mode(page) + page.fill( + "#customPyCode", + "import pandas as pd\nimport matplotlib.pyplot as plt\n" + "df = pd.read_csv('pasted_data.tsv', sep='\\t')\n" + "fig, ax = plt.subplots()\nax.plot(df.iloc[:, 0], df.iloc[:, 1])\nprint('rows', len(df))\n", + ) + run_edited_code(page) + assert "rows 4" in page.inner_text("#codeOutput") + + +def test_document_paste_with_data_loaded_warns_and_does_not_load(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "utf8.csv")) + wait_data_ready(page) + wait_settled(page) + before = load_count(page) + page.evaluate("document.activeElement && document.activeElement.blur()") + assert paste_text(page, EXCEL_TABLE, selector=None) is True + page.wait_for_timeout(600) + assert load_count(page) == before + assert page.inner_text("#fileNameLabel") == "utf8.csv" + assert status_kind(page) == "warning" and PASTE_WHILE_LOADED in status_text(page) + + +def test_paste_area_replaces_even_when_data_is_loaded(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "utf8.csv")) + wait_data_ready(page) + wait_settled(page) + paste_and_wait(page, EXCEL_TABLE) + assert "貼り付けたデータ" in page.inner_text("#fileNameLabel") + + +def test_overlay_hides_when_dragleave_has_no_types(start, fixtures_dir): + page = start + drag_event(page, "dragenter", "body", [fixture_file(fixtures_dir, "utf8.csv")]) + assert drop_overlay_visible(page) + drag_event(page, "dragleave", "body") # Safari: types が空になる + assert not drop_overlay_visible(page) diff --git a/tests/e2e/test_load_options.py b/tests/e2e/test_load_options.py new file mode 100644 index 0000000..315f14b --- /dev/null +++ b/tests/e2e/test_load_options.py @@ -0,0 +1,216 @@ +"""データ取り込みの設定(D2 シート / D3 ヘッダより前の行 / D4 桁区切り・小数点・コメント)と、コードの「最新でない」表示。""" +import pytest + +from helpers import ( + change_load_option, + code_generation, + code_is_stale, + code_text, + enter_edit_mode, + image_generation, + image_summary, + load_count, + load_fixture, + open_code_tab, + plot_src, + set_load_options, + set_save_dpi, + sheet_options, + status_kind, + status_text, + wait_app_ready, + wait_code_generated, + wait_code_contains, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def start(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + return page + + +def select_xy(page, x="__idx__0", y="__idx__1"): + page.select_option("#series-s1-x", x) + page.select_option("#series-s1-y", y) + wait_settled(page) + + +def test_second_sheet_changes_columns_plot_and_code(start, fixtures_dir): + page = start + assert not page.is_visible("#sheetGroup") + load_fixture(page, fixtures_dir / "multi_sheet.xlsx") + wait_settled(page) + assert page.is_visible("#sheetGroup") and sheet_options(page) == ["Sheet1", "二枚目"] + assert page.input_value("#sheetSelect") == "Sheet1" + assert "時間 [0]" in page.inner_text("#series-s1-y") + before = plot_src(page) + change_load_option(page, lambda: page.select_option("#sheetSelect", "二枚目")) + assert page.input_value("#sheetSelect") == "二枚目" + options = page.inner_text("#series-s1-y") + assert "z [2]" in options and "時間 [0]" not in options + select_xy(page) + wait_code_generated(page) + open_code_tab(page) + assert 'sheet_name="二枚目",' in code_text(page) + assert plot_src(page) != before + # 新しいファイルを選ぶと先頭のシートに戻る + load_fixture(page, fixtures_dir / "preamble.xlsx") + wait_settled(page) + assert page.input_value("#sheetSelect") == "データ" and sheet_options(page) == ["データ", "説明つき"] + # CSV ではシートの欄は出ない + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + assert not page.is_visible("#sheetGroup") + + +def test_preamble_needs_skip_lines_and_is_shown(start, fixtures_dir): + page = start + page.set_input_files("#fileInput", str(fixtures_dir / "preamble.csv")) + page.wait_for_function("document.documentElement.dataset.dataState === 'error'") + assert status_kind(page) == "error" and "ヘッダより前に読み飛ばす行数" in status_text(page) + change_load_option(page, lambda: page.fill("#skipLines", "3")) + assert status_kind(page) == "ok" + page.click("#tabDataBtn") + box = page.inner_text("#dataPreamble") + assert "ヘッダより前に読み飛ばした行(3行)" in box and "装置: テスト用ロガー" in box and "備考: この行はデータではありません" in box + assert "…ほか" not in box + assert "時間 [0]" in page.inner_text("#series-s1-y") + # 0 に戻すと説明の行は出ない(読み込みはまた失敗する) + page.fill("#skipLines", "0") + page.wait_for_function("document.documentElement.dataset.dataState === 'error'") + + +def test_preamble_box_for_xlsx_and_overflow(start, fixtures_dir): + page = start + set_load_options(page, skip_lines=2) + load_fixture(page, fixtures_dir / "multi_sheet.xlsx") # 先頭シートは見出しが1行目なので、2行読み飛ばすと見出しがデータ行になる + wait_settled(page) + page.click("#tabDataBtn") + assert "ヘッダより前に読み飛ばした行(2行)" in page.inner_text("#dataPreamble") + change_load_option(page, lambda: page.select_option("#sheetSelect", "二枚目")) + assert "ヘッダより前に読み飛ばした行(2行)" in page.inner_text("#dataPreamble") + + +def test_european_numbers_are_plotted(start, fixtures_dir): + page = start + set_load_options(page, delimiter=";", thousands=".", decimal=",") + load_fixture(page, fixtures_dir / "european.csv") + wait_settled(page) + select_xy(page) + summary = image_summary(page) + assert summary[0]["lines"] == 1 and summary[0]["ylim"][1] > 1000 + + +def test_european_numbers_without_settings_get_a_hint(start, fixtures_dir): + page = start + set_load_options(page, delimiter=";") + load_fixture(page, fixtures_dir / "european.csv") + wait_settled(page) + select_xy(page) + # 数値が1つも読めないので、案内つきのエラーになる + assert status_kind(page) == "error" and "「小数点」をカンマ" in status_text(page), status_text(page) + change_load_option(page, lambda: page.select_option("#decimal", ",")) + assert "小数点" not in status_text(page) + + +def test_comment_character(start, fixtures_dir): + page = start + set_load_options(page, comment="#") + load_fixture(page, fixtures_dir / "comments.csv") + wait_settled(page) + select_xy(page) + summary = image_summary(page) + assert summary[0]["lines"] == 1 + page.click("#tabDataBtn") + assert "行数: 10" in page.inner_text("#dataArea") + + +def test_thousands_warning_hint_and_fix(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "thousands.csv") + wait_settled(page) + select_xy(page, "__idx__0", "__idx__1") + assert status_kind(page) == "error" and "「桁区切り」をカンマ" in status_text(page), status_text(page) + change_load_option(page, lambda: page.select_option("#thousands", ",")) + assert "桁区切り" not in status_text(page) + assert image_summary(page)[0]["ylim"][1] > 10000 + + +def test_code_follows_load_settings_in_sync_mode(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + assert "skiprows=0," in code_text(page) and "thousands=None," in code_text(page) + for action, expected in ( + (lambda: page.fill("#skipLines", "1"), "skiprows=1,"), + (lambda: page.select_option("#decimal", ","), 'decimal=",",'), + (lambda: page.select_option("#thousands", "."), 'thousands=".",'), + (lambda: page.fill("#commentChar", "%"), 'comment="%",'), + ): + change_load_option(page, action) + wait_code_generated(page) + assert expected in code_text(page) + assert code_generation(page) == image_generation(page) + + +def test_edit_mode_keeps_code_when_load_settings_change(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + enter_edit_mode(page) + code = code_text(page) + count = load_count(page) + page.fill("#skipLines", "1") + page.wait_for_function(f"Number(document.documentElement.dataset.loadCount) > {count}") + assert code_text(page) == code + assert "編集中" in status_text(page) + + +def test_stale_note_for_render_error_and_recovery(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + assert not code_is_stale(page) + good = code_text(page) + page.fill("#subplotLeft", "0.9") + page.fill("#subplotRight", "0.1") + page.fill("#subplotBottom", "0.1") + page.fill("#subplotTop", "0.9") + wait_settled(page) + assert status_kind(page) == "error" + page.wait_for_function("document.getElementById('customPyCode').dataset.stale === 'true'") + assert code_is_stale(page) and "最新の設定を反映していません" in page.inner_text("#codeStaleNote") + assert code_text(page) == good # 表示中のコードは変わらない + page.fill("#subplotRight", "0.95") + wait_settled(page) + wait_code_generated(page) + assert not code_is_stale(page) + assert "subplots_adjust(left=0.9" in code_text(page) + + +def test_stale_note_for_script_refresh_error_and_edit_mode(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + wait_code_generated(page) + open_code_tab(page) + set_save_dpi(page, 99999) # 保存設定の不正: コードの更新だけが失敗する + page.wait_for_function("document.getElementById('customPyCode').dataset.stale === 'true'") + assert code_is_stale(page) and status_kind(page) == "error" + set_save_dpi(page, 150) + wait_code_contains(page, "dpi=150") + assert not code_is_stale(page) + set_save_dpi(page, 99999) + page.wait_for_function("document.getElementById('customPyCode').dataset.stale === 'true'") + enter_edit_mode(page) + assert not code_is_stale(page) diff --git a/tests/e2e/test_multi_files.py b/tests/e2e/test_multi_files.py new file mode 100644 index 0000000..591dbb6 --- /dev/null +++ b/tests/e2e/test_multi_files.py @@ -0,0 +1,334 @@ +"""D6: 複数ファイル(追加・置き換え・削除、データ元、データ確認の切替、一部の失敗、編集モード、ドロップ)。""" +import pytest + +from helpers import ( + add_files, + add_series_with_source, + code_text, + drop_files, + enter_edit_mode, + file_row_ids, + file_row_states, + image_summary, + load_count, + load_fixture, + open_code_tab, + plot_src, + remove_file, + set_title, + run_edited_code, + status_kind, + status_text, + status_warnings, + wait_app_ready, + wait_code_generated, + wait_data_ready, + wait_load_count, + wait_settled, +) + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def start(page, app_url, console_log): + page.goto(app_url) + wait_app_ready(page) + return page + + +def two_files(page, fixtures_dir, first="utf8.csv", second="growth.csv"): + load_fixture(page, fixtures_dir / first) + wait_settled(page) + add_files(page, [fixtures_dir / second]) + + +def test_single_file_display_has_no_list_or_source_select(start, fixtures_dir): + page = start + assert not page.is_visible("#fileList") and not page.is_visible("#previewSource") + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + assert not page.is_visible("#fileList") and not page.is_visible("#series-s1-source") + assert "UTF-8" in page.inner_text("#encodingLabel") and page.inner_text("#fileNameLabel") == "utf8.csv" + assert page.get_attribute("#fileInput", "multiple") is not None and page.get_attribute("#addFileInput", "multiple") is not None + + +def test_add_second_file_two_series_from_different_files(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + assert file_row_ids(page) == ["file-d1", "file-d2"] + assert file_row_states(page) == {"file-d1": "ready", "file-d2": "ready"} + assert page.inner_text("#fileNameLabel") == "utf8.csv ほか 1 件" + assert page.inner_text("#encodingLabel") == "" and not page.is_visible("#sheetGroup") + assert "データ1" in page.inner_text("#file-d1") and "growth.csv" in page.inner_text("#file-d2") + assert page.is_visible("#series-s1-source") + assert page.inner_text("#series-s1-source") == "データ1: utf8.csv\nデータ2: growth.csv" + assert page.get_attribute("html", "data-data-state") == "ready" + add_series_with_source(page, "d2", "__idx__2") + wait_code_generated(page) + summary = image_summary(page) + assert summary[0]["lines"] == 2 + # 系列2のY列の選択肢は growth.csv の列 + assert "指数 [1]" in page.inner_text("#series-s2-y") and "電圧" not in page.inner_text("#series-s2-y") + assert "電圧 [1]" in page.inner_text("#series-s1-y") + open_code_tab(page) + text = code_text(page) + for needle in ("DATA_FILE_1", "DATA_FILE_2", "df2.iloc[:, 2]", "df1.iloc[:, 0]"): + assert needle in text + assert "「utf8.csv」「growth.csv」" in text + + +def test_changing_series_source_resets_columns(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + page.select_option("#series-s1-x", "__idx__0") + page.select_option("#series-s1-y", "__idx__2") + page.select_option("#series-s1-source", "d2") + assert page.input_value("#series-s1-x") == "" and page.input_value("#series-s1-y") == "" + wait_settled(page) + assert "指数 [1]" in page.inner_text("#series-s1-y") + + +def test_preview_source_switches_the_table(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + assert page.is_visible("#previewSource") is False # データ確認タブを開くまでは見えない + page.click("#tabDataBtn") + assert page.is_visible("#previewSource") + assert "電圧" in page.inner_text("#dataArea") + page.select_option("#previewSource", "d2") + assert "指数" in page.inner_text("#dataArea") and "電圧" not in page.inner_text("#dataArea") + page.fill("#skipRows", "2") + assert page.locator("#dataArea tbody tr.skipped-row").count() == 2 + + +def test_remove_file_resets_series_and_returns_to_single_display(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + add_series_with_source(page, "d2", "__idx__2") + page.click("#file-d2-remove") + wait_settled(page) + assert not page.is_visible("#fileList") and not page.is_visible("#series-s1-source") + assert page.inner_text("#fileNameLabel") == "utf8.csv" and "UTF-8" in page.inner_text("#encodingLabel") + assert page.input_value("#series-s2-y") == "" + assert "データ2(growth.csv)を削除したため、系列2の列を自動に戻しました。" in status_warnings(page) + assert image_summary(page)[0]["lines"] == 2 # 系列2は先頭ファイルの自動列で描かれる + page.click("#tabCodeBtn") + wait_code_generated(page) + assert "DATA_FILE =" in code_text(page) and "DATA_FILE_2" not in code_text(page) + + +def test_remove_first_file_resets_series_using_it(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + page.select_option("#series-s1-y", "__idx__2") + wait_settled(page) + page.click("#file-d1-remove") + wait_settled(page) + assert page.inner_text("#fileNameLabel") == "growth.csv" + assert page.input_value("#series-s1-y") == "" + assert "データ1(utf8.csv)を削除したため、系列1の列を自動に戻しました。" in status_warnings(page) + + +def test_replace_with_two_files_via_file_input(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + add_series_with_source(page, "d2", "__idx__2") + before = load_count(page) + page.set_input_files("#fileInput", [str(fixtures_dir / "categories.csv"), str(fixtures_dir / "tab.txt")]) + wait_load_count(page, before + 1) + wait_settled(page) + assert file_row_ids(page) == ["file-d1", "file-d2"] + assert "categories.csv" in page.inner_text("#file-d1") and "tab.txt" in page.inner_text("#file-d2") + assert page.input_value("#series-s2-source") == "d1" # データ元は先頭に戻る + + +def test_same_name_file_replaces_the_entry(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + add_files(page, [fixtures_dir / "growth.csv"]) + assert file_row_ids(page) == ["file-d1", "file-d2"] + add_files(page, [fixtures_dir / "tab.txt"]) + assert file_row_ids(page) == ["file-d1", "file-d2", "file-d3"] + + +def test_more_than_ten_files_warns(start, fixtures_dir): + page = start + files = [(f"f{i}.csv", b"a,b\n1,2\n3,4\n5,7\n") for i in range(12)] + before = load_count(page) + drop_files(page, files) + wait_load_count(page, before + 1) + wait_settled(page) + assert len(file_row_ids(page)) == 10 + assert "読み込めるファイルは10個までです。先頭から10個を読み込みました。" in status_warnings(page) + # 注意は、その読込の結果にだけ添える(あとの描画では繰り返さない) + set_title(page, "after") + wait_settled(page) + assert "読み込めるファイルは10個までです。先頭から10個を読み込みました。" not in status_warnings(page) + + +def test_drop_add_and_replace_zones(start, fixtures_dir): + page = start + csv = lambda name: (name, (fixtures_dir / name).read_bytes()) # noqa: E731 + before = load_count(page) + drop_files(page, [csv("utf8.csv")]) + wait_load_count(page, before + 1) + wait_settled(page) + from helpers import drag_event, drop_overlay_visible + + drag_event(page, "dragenter", "body", [csv("growth.csv")]) + assert drop_overlay_visible(page) + assert page.is_visible("#dropReplace") and page.is_visible("#dropAdd") and not page.is_visible("#dropLoad") + drag_event(page, "dragover", "#dropAdd", [csv("growth.csv")]) + assert "drop-zone-active" in page.get_attribute("#dropAdd", "class") + drag_event(page, "drop", "#dropAdd", [csv("growth.csv")]) + wait_load_count(page, before + 2) + wait_settled(page) + assert file_row_ids(page) == ["file-d1", "file-d2"] + drag_event(page, "dragenter", "body", [csv("tab.txt")]) + drag_event(page, "drop", "#dropReplace", [csv("tab.txt")]) + wait_load_count(page, before + 3) + wait_settled(page) + assert file_row_ids(page) == [] and page.inner_text("#fileNameLabel") == "tab.txt" + + +def test_one_file_failing_keeps_the_other_and_recovers(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + before = load_count(page) + page.set_input_files("#addFileInput", [str(fixtures_dir / "growth.csv")]) + wait_load_count(page, before + 1) + wait_settled(page) + # 壊れた xlsx を足す(読み込めない) + page.set_input_files("#addFileInput", [{"name": "broken.xlsx", "mimeType": "application/octet-stream", "buffer": b"not an excel file"}]) + page.wait_for_function("document.querySelector('#file-d3') && document.querySelector('#file-d3').dataset.state === 'error'") + wait_settled(page) + assert file_row_states(page) == {"file-d1": "ready", "file-d2": "ready", "file-d3": "error"} + assert page.get_attribute("html", "data-data-state") == "error" + assert page.inner_text("#file-d3 .file-row-error") != "" + assert plot_src(page).startswith("data:image/png") # ほかのファイルで描ける + assert any("データ3(broken.xlsx)を読み込めませんでした" in w for w in status_warnings(page)) + assert status_kind(page) == "warning" + # 失敗したファイルを使う系列はエラー + page.click("#addSeriesBtn") + page.select_option("#series-s2-source", "d3") + page.wait_for_function("document.getElementById('status').dataset.kind === 'error'") + assert "データ3(broken.xlsx)を読み込めていません" in status_text(page) + # 削除すると復帰する + page.click("#file-d3-remove") + page.wait_for_function("document.documentElement.dataset.dataState === 'ready'") + + +def test_failed_file_is_retried_when_load_settings_change(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + semicolon = {"name": "semi.csv", "mimeType": "text/csv", "buffer": b"a;b\n1;2\n3;4\n5;7\n"} + before = load_count(page) + page.set_input_files("#addFileInput", [semicolon]) + wait_load_count(page, before + 1) + wait_settled(page) + assert file_row_states(page)["file-d2"] == "ready" + page.fill("#delimiter", "x") # どちらも区切れず、1列のまま + page.wait_for_function("document.documentElement.dataset.dataState !== 'loading'") + wait_settled(page) + page.fill("#delimiter", "") + page.wait_for_function("document.documentElement.dataset.dataState === 'ready'") + wait_settled(page) + assert file_row_states(page) == {"file-d1": "ready", "file-d2": "ready"} + + +def test_sheet_select_reloads_only_that_file(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + add_files(page, [fixtures_dir / "multi_sheet.xlsx"]) + assert page.is_visible("#file-d2-sheet") and not page.is_visible("#file-d1-sheet") + assert page.inner_text("#file-d2") != "" and "Excel" in page.inner_text("#file-d2") + page.evaluate( + "() => { const orig = window.mplgui.loadFile; window.__ids = [];" + "window.mplgui.loadFile = function () { window.__ids.push(arguments[3]); return orig.apply(this, arguments); }; }" + ) + before = load_count(page) + page.select_option("#file-d2-sheet", "二枚目") + wait_load_count(page, before + 1) + wait_settled(page) + assert page.evaluate("window.__ids") == ["d2"] + assert page.input_value("#file-d2-sheet") == "二枚目" + page.select_option("#series-s1-source", "d2") + wait_settled(page) + assert "z [2]" in page.inner_text("#series-s1-y") + wait_code_generated(page) + open_code_tab(page) + assert 'sheet_name="二枚目",' in code_text(page) + + +def test_edit_mode_reads_both_files_from_the_work_folder(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + enter_edit_mode(page) + page.fill( + "#customPyCode", + "import pandas as pd\nimport matplotlib.pyplot as plt\n" + "a = pd.read_csv('utf8.csv')\nb = pd.read_csv('growth.csv')\n" + "fig, ax = plt.subplots()\nax.plot(a.iloc[:, 0], a.iloc[:, 1])\nax.plot(b.iloc[:, 0], b.iloc[:, 1])\n", + ) + run_edited_code(page) + assert image_summary(page)[0]["lines"] == 2 + assert status_kind(page) == "ok" + + +def test_paste_replaces_all_files_and_keeps_save_button(start, fixtures_dir): + from helpers import EXCEL_TABLE, paste_text + + page = start + two_files(page, fixtures_dir) + before = load_count(page) + paste_text(page, EXCEL_TABLE) + wait_load_count(page, before + 1) + wait_settled(page) + assert file_row_ids(page) == [] and page.is_visible("#savePastedBtn") + add_files(page, [fixtures_dir / "growth.csv"]) + assert page.is_visible("#savePastedBtn") # 貼り付けたデータが残っている間は出す + assert file_row_ids(page) == ["file-d1", "file-d2"] + + +def test_removing_every_file_returns_to_the_initial_state(start, fixtures_dir): + page = start + two_files(page, fixtures_dir) + open_code_tab(page) + assert code_text(page) != "" + before = load_count(page) + remove_file(page, "d2") + wait_settled(page) + remove_file(page, "d1") + page.wait_for_function("document.documentElement.dataset.dataState === 'none'") + assert page.locator("#plotArea img").count() == 0 + assert page.inner_text("#dataArea").strip() == "" + assert page.inner_text("#fileNameLabel") == "選択されていません" + assert page.inner_text("#encodingLabel") == "" and not page.is_visible("#sheetGroup") + assert code_text(page) == "" and page.get_attribute("#customPyCode", "data-generation") is None + assert status_text(page) == "" + assert "(自動)" in page.inner_text("#series-s1-y") and "電圧" not in page.inner_text("#series-s1-y") + assert load_count(page) == before # 取り除くだけでは読込として数えない + load_fixture(page, fixtures_dir / "utf8.csv") # また読み込める + wait_settled(page) + assert plot_src(page).startswith("data:image/png") + + +def test_series_cards_are_kept_when_a_load_leaves_columns_unchanged(start, fixtures_dir): + page = start + load_fixture(page, fixtures_dir / "utf8.csv") + wait_settled(page) + page.evaluate("document.getElementById('series-s1-legend-name').dataset.mark = 'kept'") + before = load_count(page) + page.fill("#delimiter", ",") # 同じ区切り文字を明示するだけ。読み直しても列は同じ + wait_load_count(page, before + 1) + wait_settled(page) + assert page.evaluate("document.getElementById('series-s1-legend-name').dataset.mark") == "kept" + # 列が変わる読込では描き直す + page.fill("#delimiter", "x") + wait_load_count(page, before + 2) + wait_settled(page) + assert page.evaluate("document.getElementById('series-s1-legend-name').dataset.mark") is None diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index f5ac6ca..67cda85 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -14,3 +14,12 @@ | `non_numeric.csv` | 「1,234」形式の数値、「12 mV」のような単位付き、N/A、空欄が混在 | | `growth.csv` | 時間 / 指数 / 線形。値はすべて正(対数軸で描ける)。生成コードの一致確認用 | | `categories.csv` | 品目(文字列)/ 売上A / 売上B。文字列カテゴリの棒グラフ用 | +| `preamble.csv` | 先頭に説明の行が3行(3行目はカンマの数が違う。skipLines 無しでは列数の不整合で読めない)。skipLines=3 で読める | +| `european.csv` | 区切り `;`、小数点 `,`、桁区切り `.`(`1.234,5`、負の値あり) | +| `thousands.csv` | カンマ区切りで、引用符付きの桁区切り(`"1,234"`、負の値あり) | +| `comments.csv` | `#` の行全体のコメントと、データ行末尾の `# note` | +| `preamble.xlsx` | 2 シート(データ / 説明つき)。2 枚目は見出しの前に説明が2行 | +| `datetime.csv` | 日時(`2025/12/31 22:00` から1時間刻みで150行、年をまたぐ。120行目は `測定エラー`、130行目は空欄)/ ID(`A-001`)/ 通番(数値の ID)/ 温度 / 湿度。先頭100行は正しい日時なので日時の列として認識され、読めない1件は警告になる | +| `datetime_iso_tz.csv` | ISO 8601。時刻(すべて `+09:00`)、混在(`Z` と `+09:00` が交互。UTC にそろえる)、値 | +| `dates_ja.csv` | `2026年1月5日` 形式の日付(時刻なし、8行)と売上。棒グラフ用 | +| `datetime.xlsx` | Excel の日時セル(datetime 型、6時間刻みで24行)/ 値 / 温度 | diff --git a/tests/fixtures/comments.csv b/tests/fixtures/comments.csv new file mode 100644 index 0000000..ad64b48 --- /dev/null +++ b/tests/fixtures/comments.csv @@ -0,0 +1,13 @@ +# 全体のコメント +x,y +0,0 +1,1 +2,4 +3,9 +4,16 # note +5,25 +6,36 +# 途中のコメント行 +7,49 +8,64 +9,81 diff --git a/tests/fixtures/dates_ja.csv b/tests/fixtures/dates_ja.csv new file mode 100644 index 0000000..bd94579 --- /dev/null +++ b/tests/fixtures/dates_ja.csv @@ -0,0 +1,9 @@ +日付,売上 +2026年1月5日,100 +2026年1月6日,113 +2026年1月7日,126 +2026年1月8日,139 +2026年1月9日,105 +2026年1月10日,118 +2026年1月11日,131 +2026年1月12日,144 diff --git a/tests/fixtures/datetime.csv b/tests/fixtures/datetime.csv new file mode 100644 index 0000000..82a7b37 --- /dev/null +++ b/tests/fixtures/datetime.csv @@ -0,0 +1,151 @@ +日時,ID,通番,温度,湿度 +2025/12/31 22:00,A-001,1001,15.0,60.0 +2025/12/31 23:00,A-002,1002,15.623,59.9 +2026/01/01 00:00,A-003,1003,16.237,59.8 +2026/01/01 01:00,A-004,1004,16.831,59.7 +2026/01/01 02:00,A-005,1005,17.397,59.6 +2026/01/01 03:00,A-006,1006,17.925,59.5 +2026/01/01 04:00,A-007,1007,18.408,59.4 +2026/01/01 05:00,A-008,1008,18.838,59.3 +2026/01/01 06:00,A-009,1009,19.207,59.2 +2026/01/01 07:00,A-010,1010,19.511,59.1 +2026/01/01 08:00,A-011,1011,19.745,59.0 +2026/01/01 09:00,A-012,1012,19.904,58.9 +2026/01/01 10:00,A-013,1013,19.987,58.8 +2026/01/01 11:00,A-014,1014,19.993,58.7 +2026/01/01 12:00,A-015,1015,19.92,58.6 +2026/01/01 13:00,A-016,1016,19.77,58.5 +2026/01/01 14:00,A-017,1017,19.546,58.4 +2026/01/01 15:00,A-018,1018,19.252,58.3 +2026/01/01 16:00,A-019,1019,18.89,58.2 +2026/01/01 17:00,A-020,1020,18.468,58.1 +2026/01/01 18:00,A-021,1021,17.992,58.0 +2026/01/01 19:00,A-022,1022,17.47,57.9 +2026/01/01 20:00,A-023,1023,16.908,57.8 +2026/01/01 21:00,A-024,1024,16.317,57.7 +2026/01/01 22:00,A-025,1025,15.706,57.6 +2026/01/01 23:00,A-026,1026,15.083,57.5 +2026/01/02 00:00,A-027,1027,14.459,57.4 +2026/01/02 01:00,A-028,1028,13.844,57.3 +2026/01/02 02:00,A-029,1029,13.246,57.2 +2026/01/02 03:00,A-030,1030,12.676,57.1 +2026/01/02 04:00,A-031,1031,12.142,57.0 +2026/01/02 05:00,A-032,1032,11.653,56.9 +2026/01/02 06:00,A-033,1033,11.216,56.8 +2026/01/02 07:00,A-034,1034,10.838,56.7 +2026/01/02 08:00,A-035,1035,10.525,56.6 +2026/01/02 09:00,A-036,1036,10.282,56.5 +2026/01/02 10:00,A-037,1037,10.112,56.4 +2026/01/02 11:00,A-038,1038,10.019,56.3 +2026/01/02 12:00,A-039,1039,10.004,56.2 +2026/01/02 13:00,A-040,1040,10.066,56.1 +2026/01/02 14:00,A-041,1041,10.205,56.0 +2026/01/02 15:00,A-042,1042,10.42,55.9 +2026/01/02 16:00,A-043,1043,10.705,55.8 +2026/01/02 17:00,A-044,1044,11.058,55.7 +2026/01/02 18:00,A-045,1045,11.472,55.6 +2026/01/02 19:00,A-046,1046,11.942,55.5 +2026/01/02 20:00,A-047,1047,12.459,55.4 +2026/01/02 21:00,A-048,1048,13.015,55.3 +2026/01/02 22:00,A-049,1049,13.603,55.2 +2026/01/02 23:00,A-050,1050,14.212,55.1 +2026/01/03 00:00,A-051,1051,14.834,55.0 +2026/01/03 01:00,A-052,1052,15.458,54.9 +2026/01/03 02:00,A-053,1053,16.076,54.8 +2026/01/03 03:00,A-054,1054,16.676,54.7 +2026/01/03 04:00,A-055,1055,17.25,54.6 +2026/01/03 05:00,A-056,1056,17.789,54.5 +2026/01/03 06:00,A-057,1057,18.285,54.4 +2026/01/03 07:00,A-058,1058,18.729,54.3 +2026/01/03 08:00,A-059,1059,19.115,54.2 +2026/01/03 09:00,A-060,1060,19.437,54.1 +2026/01/03 10:00,A-061,1061,19.69,54.0 +2026/01/03 11:00,A-062,1062,19.869,53.9 +2026/01/03 12:00,A-063,1063,19.973,53.8 +2026/01/03 13:00,A-064,1064,19.999,53.7 +2026/01/03 14:00,A-065,1065,19.947,53.6 +2026/01/03 15:00,A-066,1066,19.817,53.5 +2026/01/03 16:00,A-067,1067,19.613,53.4 +2026/01/03 17:00,A-068,1068,19.337,53.3 +2026/01/03 18:00,A-069,1069,18.992,53.2 +2026/01/03 19:00,A-070,1070,18.586,53.1 +2026/01/03 20:00,A-071,1071,18.124,53.0 +2026/01/03 21:00,A-072,1072,17.612,52.9 +2026/01/03 22:00,A-073,1073,17.061,52.8 +2026/01/03 23:00,A-074,1074,16.477,52.7 +2026/01/04 00:00,A-075,1075,15.869,52.6 +2026/01/04 01:00,A-076,1076,15.249,52.5 +2026/01/04 02:00,A-077,1077,14.624,52.4 +2026/01/04 03:00,A-078,1078,14.006,52.3 +2026/01/04 04:00,A-079,1079,13.402,52.2 +2026/01/04 05:00,A-080,1080,12.824,52.1 +2026/01/04 06:00,A-081,1081,12.28,52.0 +2026/01/04 07:00,A-082,1082,11.778,51.9 +2026/01/04 08:00,A-083,1083,11.327,51.8 +2026/01/04 09:00,A-084,1084,10.932,51.7 +2026/01/04 10:00,A-085,1085,10.602,51.6 +2026/01/04 11:00,A-086,1086,10.339,51.5 +2026/01/04 12:00,A-087,1087,10.15,51.4 +2026/01/04 13:00,A-088,1088,10.036,51.3 +2026/01/04 14:00,A-089,1089,10.0,51.2 +2026/01/04 15:00,A-090,1090,10.042,51.1 +2026/01/04 16:00,A-091,1091,10.161,51.0 +2026/01/04 17:00,A-092,1092,10.356,50.9 +2026/01/04 18:00,A-093,1093,10.623,50.8 +2026/01/04 19:00,A-094,1094,10.958,50.7 +2026/01/04 20:00,A-095,1095,11.357,50.6 +2026/01/04 21:00,A-096,1096,11.812,50.5 +2026/01/04 22:00,A-097,1097,12.317,50.4 +2026/01/04 23:00,A-098,1098,12.864,50.3 +2026/01/05 00:00,A-099,1099,13.444,50.2 +2026/01/05 01:00,A-100,1100,14.049,50.1 +2026/01/05 02:00,A-101,1101,14.668,50.0 +2026/01/05 03:00,A-102,1102,15.293,49.9 +2026/01/05 04:00,A-103,1103,15.913,49.8 +2026/01/05 05:00,A-104,1104,16.519,49.7 +2026/01/05 06:00,A-105,1105,17.101,49.6 +2026/01/05 07:00,A-106,1106,17.65,49.5 +2026/01/05 08:00,A-107,1107,18.158,49.4 +2026/01/05 09:00,A-108,1108,18.617,49.3 +2026/01/05 10:00,A-109,1109,19.019,49.2 +2026/01/05 11:00,A-110,1110,19.358,49.1 +2026/01/05 12:00,A-111,1111,19.63,49.0 +2026/01/05 13:00,A-112,1112,19.829,48.9 +2026/01/05 14:00,A-113,1113,19.953,48.8 +2026/01/05 15:00,A-114,1114,20.0,48.7 +2026/01/05 16:00,A-115,1115,19.968,48.6 +2026/01/05 17:00,A-116,1116,19.859,48.5 +2026/01/05 18:00,A-117,1117,19.674,48.4 +2026/01/05 19:00,A-118,1118,19.417,48.3 +2026/01/05 20:00,A-119,1119,19.09,48.2 +2026/01/05 21:00,A-120,1120,18.7,48.1 +測定エラー,A-121,1121,18.251,48.0 +2026/01/05 23:00,A-122,1122,17.753,47.9 +2026/01/06 00:00,A-123,1123,17.211,47.8 +2026/01/06 01:00,A-124,1124,16.634,47.7 +2026/01/06 02:00,A-125,1125,16.032,47.6 +2026/01/06 03:00,A-126,1126,15.414,47.5 +2026/01/06 04:00,A-127,1127,14.79,47.4 +2026/01/06 05:00,A-128,1128,14.169,47.3 +2026/01/06 06:00,A-129,1129,13.56,47.2 +2026/01/06 07:00,A-130,1130,12.975,47.1 +,A-131,1131,12.421,47.0 +2026/01/06 09:00,A-132,1132,11.907,46.9 +2026/01/06 10:00,A-133,1133,11.441,46.8 +2026/01/06 11:00,A-134,1134,11.031,46.7 +2026/01/06 12:00,A-135,1135,10.683,46.6 +2026/01/06 13:00,A-136,1136,10.402,46.5 +2026/01/06 14:00,A-137,1137,10.193,46.4 +2026/01/06 15:00,A-138,1138,10.059,46.3 +2026/01/06 16:00,A-139,1139,10.002,46.2 +2026/01/06 17:00,A-140,1140,10.023,46.1 +2026/01/06 18:00,A-141,1141,10.122,46.0 +2026/01/06 19:00,A-142,1142,10.297,45.9 +2026/01/06 20:00,A-143,1143,10.545,45.8 +2026/01/06 21:00,A-144,1144,10.863,45.7 +2026/01/06 22:00,A-145,1145,11.245,45.6 +2026/01/06 23:00,A-146,1146,11.686,45.5 +2026/01/07 00:00,A-147,1147,12.179,45.4 +2026/01/07 01:00,A-148,1148,12.715,45.3 +2026/01/07 02:00,A-149,1149,13.288,45.2 +2026/01/07 03:00,A-150,1150,13.887,45.1 diff --git a/tests/fixtures/datetime.xlsx b/tests/fixtures/datetime.xlsx new file mode 100644 index 0000000..92bc72a Binary files /dev/null and b/tests/fixtures/datetime.xlsx differ diff --git a/tests/fixtures/datetime_iso_tz.csv b/tests/fixtures/datetime_iso_tz.csv new file mode 100644 index 0000000..4e8833b --- /dev/null +++ b/tests/fixtures/datetime_iso_tz.csv @@ -0,0 +1,31 @@ +時刻,混在,値 +2026-01-05T09:00:00+09:00,2026-01-05T09:00:00Z,10.0 +2026-01-05T10:00:00+09:00,2026-01-05T10:00:00+09:00,10.5 +2026-01-05T11:00:00+09:00,2026-01-05T11:00:00Z,11.0 +2026-01-05T12:00:00+09:00,2026-01-05T12:00:00+09:00,11.5 +2026-01-05T13:00:00+09:00,2026-01-05T13:00:00Z,12.0 +2026-01-05T14:00:00+09:00,2026-01-05T14:00:00+09:00,12.5 +2026-01-05T15:00:00+09:00,2026-01-05T15:00:00Z,13.0 +2026-01-05T16:00:00+09:00,2026-01-05T16:00:00+09:00,13.5 +2026-01-05T17:00:00+09:00,2026-01-05T17:00:00Z,14.0 +2026-01-05T18:00:00+09:00,2026-01-05T18:00:00+09:00,14.5 +2026-01-05T19:00:00+09:00,2026-01-05T19:00:00Z,15.0 +2026-01-05T20:00:00+09:00,2026-01-05T20:00:00+09:00,15.5 +2026-01-05T21:00:00+09:00,2026-01-05T21:00:00Z,16.0 +2026-01-05T22:00:00+09:00,2026-01-05T22:00:00+09:00,16.5 +2026-01-05T23:00:00+09:00,2026-01-05T23:00:00Z,17.0 +2026-01-06T00:00:00+09:00,2026-01-06T00:00:00+09:00,17.5 +2026-01-06T01:00:00+09:00,2026-01-06T01:00:00Z,18.0 +2026-01-06T02:00:00+09:00,2026-01-06T02:00:00+09:00,18.5 +2026-01-06T03:00:00+09:00,2026-01-06T03:00:00Z,19.0 +2026-01-06T04:00:00+09:00,2026-01-06T04:00:00+09:00,19.5 +2026-01-06T05:00:00+09:00,2026-01-06T05:00:00Z,20.0 +2026-01-06T06:00:00+09:00,2026-01-06T06:00:00+09:00,20.5 +2026-01-06T07:00:00+09:00,2026-01-06T07:00:00Z,21.0 +2026-01-06T08:00:00+09:00,2026-01-06T08:00:00+09:00,21.5 +2026-01-06T09:00:00+09:00,2026-01-06T09:00:00Z,22.0 +2026-01-06T10:00:00+09:00,2026-01-06T10:00:00+09:00,22.5 +2026-01-06T11:00:00+09:00,2026-01-06T11:00:00Z,23.0 +2026-01-06T12:00:00+09:00,2026-01-06T12:00:00+09:00,23.5 +2026-01-06T13:00:00+09:00,2026-01-06T13:00:00Z,24.0 +2026-01-06T14:00:00+09:00,2026-01-06T14:00:00+09:00,24.5 diff --git a/tests/fixtures/european.csv b/tests/fixtures/european.csv new file mode 100644 index 0000000..bde4923 --- /dev/null +++ b/tests/fixtures/european.csv @@ -0,0 +1,14 @@ +番号;値;比 +1;1.234,5;0,00 +2;2.469,0;0,25 +3;3.703,5;0,50 +4;4.938,0;0,75 +5;6.172,5;1,00 +6;7.407,0;1,25 +7;8.641,5;1,50 +8;9.876,0;1,75 +9;11.110,5;2,00 +10;12.345,0;2,25 +11;13.579,5;2,50 +12;14.814,0;2,75 +13;-1.234.567,5;-0,5 diff --git a/tests/fixtures/make_fixtures.py b/tests/fixtures/make_fixtures.py index 9fd1bb3..bcaee27 100644 --- a/tests/fixtures/make_fixtures.py +++ b/tests/fixtures/make_fixtures.py @@ -4,7 +4,7 @@ """ import math import re -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path import openpyxl @@ -89,6 +89,49 @@ def main(): wb.save(OUT / "multi_sheet.xlsx") _normalize_zip(OUT / "multi_sheet.xlsx") + # 前置き行(装置の説明)が3行。3行目はカンマの数が違うので、skipLines 無しでは表として読めない + pre = [ + "装置: テスト用ロガー,型番: X-100,単位: V", + "備考: この行はデータではありません", + "測定日: 2026-01-01,担当: 合成データ,単位: V と mA,備考: 5列,終", + ] + (OUT / "preamble.csv").write_bytes(("\n".join(pre) + "\n" + text).encode("utf-8")) + + # ヨーロッパ式の数値: 区切り ;、小数点 ,、桁区切り .(1.234,5) + eu = [(i + 1, f"{(i + 1) * 1234.5:,.1f}".replace(",", "_").replace(".", ",").replace("_", "."), f"{i * 0.25:.2f}".replace(".", ",")) for i in range(12)] + eu.append((13, "-1.234.567,5", "-0,5")) + (OUT / "european.csv").write_bytes(csv_text(["番号", "値", "比"], eu, sep=";").encode("utf-8")) + + # 引用符付きの桁区切り(カンマ区切りで "1,234")。負の値も含む + th = [(i + 1, f'"{(i + 1) * 1234:,}"', f'"{-(i + 1) * 98765.5:,.1f}"') for i in range(12)] + (OUT / "thousands.csv").write_bytes(csv_text(["番号", "金額", "差額"], th).encode("utf-8")) + + # コメント: 行全体の # と、データ行の末尾の # note + cm = ["# 全体のコメント", "x,y"] + for i in range(10): + cm.append(f"{i},{i * i}" + (" # note" if i == 4 else "")) + if i == 6: + cm.append("# 途中のコメント行") + (OUT / "comments.csv").write_bytes(("\n".join(cm) + "\n").encode("utf-8")) + + # 2 シート。2 枚目は見出しの前に説明の行が 2 行ある + wb2 = openpyxl.Workbook() + p1 = wb2.active + p1.title = "データ" + p1.append(["t", "v"]) + for i in range(15): + p1.append([i, i * 2]) + p2 = wb2.create_sheet("説明つき") + p2.append(["測定条件: 合成データ"]) + p2.append(["単位: V"]) + p2.append(["t", "v", "w"]) + for i in range(15): + p2.append([i, i * i, 100 - i]) + wb2.properties.created = fixed + wb2.properties.modified = fixed + wb2.save(OUT / "preamble.xlsx") + _normalize_zip(OUT / "preamble.xlsx") + dup = ["温度", "温度", "値"] # 同名列(温度)で値が異なる d = [(20 + i * 0.5, 100 - i * 2, round(math.sqrt(i + 1), 4)) for i in range(N)] @@ -102,6 +145,51 @@ def main(): lines.append(f"{i},{amount},{volt},{unit}") (OUT / "non_numeric.csv").write_bytes(("\n".join(lines) + "\n").encode("utf-8")) + make_datetime_fixtures(fixed) + + +def make_datetime_fixtures(fixed): + """日時の列(D5)。年をまたぐ時間刻み、ISO 8601(タイムゾーン付き)、日本語の日付、Excel の日時セル。""" + # 時間刻み。値は 150 行(先頭 100 行は正しい日時。120 行目は読めない値、130 行目は空欄) + t0 = datetime(2025, 12, 31, 22, 0) + lines = ["日時,ID,通番,温度,湿度"] + for i in range(150): + stamp = (t0 + timedelta(hours=i)).strftime("%Y/%m/%d %H:%M") + if i == 120: + stamp = "測定エラー" + elif i == 130: + stamp = "" + lines.append(f"{stamp},A-{i + 1:03d},{1001 + i},{round(15 + 5 * math.sin(i / 8), 3)},{round(60 - i * 0.1, 2)}") + (OUT / "datetime.csv").write_bytes(("\n".join(lines) + "\n").encode("utf-8")) + + # ISO 8601: 時刻は同じオフセット(+09:00)、混在は Z と +09:00 が交互 + iso = ["時刻,混在,値"] + base = datetime(2026, 1, 5, 9, 0) + for i in range(30): + local = (base + timedelta(hours=i)).strftime("%Y-%m-%dT%H:%M:%S") + mixed = local + ("Z" if i % 2 == 0 else "+09:00") + iso.append(f"{local}+09:00,{mixed},{round(10 + i * 0.5, 2)}") + (OUT / "datetime_iso_tz.csv").write_bytes(("\n".join(iso) + "\n").encode("utf-8")) + + # 日本語の日付(時刻なし)。棒グラフ用 + ja = ["日付,売上"] + for i in range(8): + day = datetime(2026, 1, 5) + timedelta(days=i) + ja.append(f"{day.year}年{day.month}月{day.day}日,{100 + i * 13 % 47}") + (OUT / "dates_ja.csv").write_bytes(("\n".join(ja) + "\n").encode("utf-8")) + + # Excel の日時セル(もともと datetime 型) + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Sheet1" + ws.append(["日時", "値", "温度"]) + for i in range(24): + ws.append([datetime(2026, 1, 5, 12, 0) + timedelta(hours=6 * i), i * 2, round(20 + math.sin(i / 3), 3)]) + wb.properties.created = fixed + wb.properties.modified = fixed + wb.save(OUT / "datetime.xlsx") + _normalize_zip(OUT / "datetime.xlsx") + if __name__ == "__main__": main() diff --git a/tests/fixtures/preamble.csv b/tests/fixtures/preamble.csv new file mode 100644 index 0000000..e88d6d9 --- /dev/null +++ b/tests/fixtures/preamble.csv @@ -0,0 +1,34 @@ +装置: テスト用ロガー,型番: X-100,単位: V +備考: この行はデータではありません +測定日: 2026-01-01,担当: 合成データ,単位: V と mA,備考: 5列,終 +時間,電圧,電流 +0.0,0.0,1.5 +0.1,0.1987,1.4777 +0.2,0.3894,1.4127 +0.3,0.5646,1.3108 +0.4,0.7174,1.1812 +0.5,0.8415,1.0354 +0.6,0.932,0.8864 +0.7,0.9854,0.7476 +0.8,0.9996,0.6313 +0.9,0.9738,0.548 +1.0,0.9093,0.505 +1.1,0.8085,0.5063 +1.2,0.6755,0.5516 +1.3,0.5155,0.637 +1.4,0.335,0.7549 +1.5,0.1411,0.8946 +1.6,-0.0584,1.0437 +1.7,-0.2555,1.189 +1.8,-0.4425,1.3173 +1.9,-0.6119,1.4174 +2.0,-0.7568,1.4801 +2.1,-0.8716,1.4999 +2.2,-0.9516,1.4751 +2.3,-0.9937,1.4079 +2.4,-0.9962,1.3042 +2.5,-0.9589,1.1733 +2.6,-0.8835,1.027 +2.7,-0.7728,0.8782 +2.8,-0.6313,0.7404 +2.9,-0.4646,0.6257 diff --git a/tests/fixtures/preamble.xlsx b/tests/fixtures/preamble.xlsx new file mode 100644 index 0000000..bff5f95 Binary files /dev/null and b/tests/fixtures/preamble.xlsx differ diff --git a/tests/fixtures/thousands.csv b/tests/fixtures/thousands.csv new file mode 100644 index 0000000..65b2c6d --- /dev/null +++ b/tests/fixtures/thousands.csv @@ -0,0 +1,13 @@ +番号,金額,差額 +1,"1,234","-98,765.5" +2,"2,468","-197,531.0" +3,"3,702","-296,296.5" +4,"4,936","-395,062.0" +5,"6,170","-493,827.5" +6,"7,404","-592,593.0" +7,"8,638","-691,358.5" +8,"9,872","-790,124.0" +9,"11,106","-888,889.5" +10,"12,340","-987,655.0" +11,"13,574","-1,086,420.5" +12,"14,808","-1,185,186.0" diff --git a/tests/perf/bench.py b/tests/perf/bench.py index 8f6fdd3..6af156d 100644 --- a/tests/perf/bench.py +++ b/tests/perf/bench.py @@ -18,7 +18,7 @@ REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO / "tests" / "e2e")) from helpers import ( # noqa: E402 - enter_edit_mode, image_generation, load_fixture, plot_src, + add_series_with_source, enter_edit_mode, image_generation, load_fixture, paste_text, plot_src, set_save_dpi, wait_app_ready, wait_copy_state, wait_data_ready, wait_latin_state, wait_plot_changed, ) @@ -294,6 +294,156 @@ def bench_japanese_font(browser, url, reps): return total, after_ready +# --- Phase 4: データ読込の追加機能(貼り付け・シート切替・日時・複数ファイル) --- +# シートの切り替えは change イベント(select)。測り方は CHANGE_JS と同じ。 +SHEET_JS = CHANGE_JS.replace("'input'", "'change'") + + +def make_tsv(rows=10_000, ys=5): + import math + lines = ["\t".join(["x"] + [f"y{k + 1}" for k in range(ys)])] + for i in range(rows): + x = i * 0.01 + lines.append("\t".join([str(round(x, 4))] + [str(round(math.sin(x * (k + 1)) + 0.1 * k, 5)) for k in range(ys)])) + return "\r\n".join(lines) + "\r\n" + + +def make_xlsx(path, rows=10_000, sheets=2): + import math + from openpyxl import Workbook + wb = Workbook(write_only=True) + for s in range(sheets): + ws = wb.create_sheet(f"シート{s + 1}") + ws.append(["x", "y1", "y2", "y3", "y4", "y5"]) + for i in range(rows): + x = i * 0.01 + ws.append([round(x, 4)] + [round(math.sin(x * (k + 1) + s) + 0.1 * k, 5) for k in range(5)]) + wb.save(path) + + +def make_datetime_csv(path, rows=10_000): + import datetime + import math + t0 = datetime.datetime(2026, 1, 1) + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["time", "y1", "y2", "y3", "y4", "y5"]) + for i in range(rows): + x = i * 0.01 + t = t0 + datetime.timedelta(seconds=30 * i) + w.writerow([t.strftime("%Y/%m/%d %H:%M:%S")] + [round(math.sin(x * (k + 1)) + 0.1 * k, 5) for k in range(5)]) + + +def bench_paste(browser, url, reps, big_text, small_text): + """貼り付け(ClipboardEvent を #pasteArea へ)→ 最初のプロット。終了条件は「初回ファイル読込」と同じ。""" + out = {} + ctx = browser.new_context() + p0 = ctx.new_page() + p0.goto(url, timeout=TIMEOUT) + wait_app_ready(p0, TIMEOUT) + paste_text(p0, small_text) + wait_data_ready(p0, TIMEOUT) + wait_plot_changed(p0, "", TIMEOUT) + p0.close() + for name, text in (("paste_10000x6_first_plot_ms", big_text), ("paste_small_30rows_first_plot_ms", small_text)): + out[name] = [] + for _ in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + paste_text(page, text) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + out[name].append((time.perf_counter() - t) * 1000) + page.close() + ctx.close() + return out + + +def bench_sheet_switch(browser, url, reps, xlsx_path): + """2 シート × 10,000 行の xlsx。(a) 初回読込 → 最初のプロット(Excel 用 wheel 取得込み、HTTP キャッシュは温めた状態)、 + (b) 系列5本を設定した後、#sheetSelect で 2 枚目へ → 新しい画像(change イベントから。読み直し + 描画)。""" + first, switch = [], [] + ctx = browser.new_context() + p0 = ctx.new_page() + p0.goto(url, timeout=TIMEOUT) + wait_app_ready(p0, TIMEOUT) + load_fixture(p0, xlsx_path) + wait_plot_changed(p0, "", TIMEOUT) + p0.close() + for _ in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + page.set_input_files("#fileInput", str(xlsx_path)) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + first.append((time.perf_counter() - t) * 1000) + setup_series(page) # 既定の系列は X 列そのものを描くので、シートで絵が変わるよう 5 系列にしておく + page.wait_for_timeout(3000) + switch.append(page.evaluate(SHEET_JS, ["#sheetSelect", "シート2"])) + page.close() + ctx.close() + return first, switch + + +def bench_datetime(browser, url, reps, dt_path): + """日時の列(%Y/%m/%d %H:%M:%S)+ 数値5列、10,000 行。X = 日時の列、系列5本。""" + first, change = [], [] + ctx = _warm_context(browser, url, dt_path) + for i in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + page.set_input_files("#fileInput", str(dt_path)) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + first.append((time.perf_counter() - t) * 1000) + setup_series(page) + page.wait_for_timeout(3000) + change.append(page.evaluate(CHANGE_JS, ["#title", f"bench title {i}"])) + page.close() + ctx.close() + return first, change + + +def bench_two_files(browser, url, reps, path1, path2): + """2つの 10,000 行 CSV を #fileInput(multiple)で同時に選ぶ → 最初のプロット。 + 設定変更: 系列1 はファイル1、系列2 はファイル2 の 2 系列でタイトルを変える。""" + first, change = [], [] + ctx = browser.new_context() + p0 = ctx.new_page() + p0.goto(url, timeout=TIMEOUT) + wait_app_ready(p0, TIMEOUT) + p0.set_input_files("#fileInput", [str(path1), str(path2)]) + wait_data_ready(p0, TIMEOUT) + wait_plot_changed(p0, "", TIMEOUT) + p0.close() + for i in range(reps): + page = ctx.new_page() + page.set_default_timeout(TIMEOUT) + page.goto(url) + wait_app_ready(page, TIMEOUT) + t = time.perf_counter() + page.set_input_files("#fileInput", [str(path1), str(path2)]) + wait_data_ready(page, TIMEOUT) + wait_plot_changed(page, "", TIMEOUT) + first.append((time.perf_counter() - t) * 1000) + page.wait_for_timeout(2000) + add_series_with_source(page, "d2", y="__idx__1", series_id="s2") + page.wait_for_timeout(3000) + change.append(page.evaluate(CHANGE_JS, ["#title", f"bench title {i}"])) + page.close() + ctx.close() + return first, change + + def fmt(name, xs): s = stats(xs) return f"{name:<44} median {s['median']:9.0f} min {s['min']:9.0f} max {s['max']:9.0f} (ms, n={s['n']})" @@ -304,40 +454,68 @@ def main(): ap.add_argument("--reps", type=int, default=5) ap.add_argument("--headed", action="store_true") ap.add_argument("--json") + ap.add_argument("--only", choices=["old", "new"], help="old: Phase 3 までの項目だけ / new: Phase 4 の項目だけ") a = ap.parse_args() serve = Path(os.environ.get("E2E_SERVE_DIR") or REPO) srv, url = start_server(serve) tmp = Path(tempfile.mkdtemp(prefix="bench_")) csv_path = tmp / "bench_10000x6.csv" make_csv(csv_path) + csv2_path = tmp / "bench_10000x6_b.csv" + make_csv(csv2_path) + xlsx_path = tmp / "bench_2sheets_10000x6.xlsx" + make_xlsx(xlsx_path) + dt_path = tmp / "bench_datetime_10000x6.csv" + make_datetime_csv(dt_path) + big_tsv = make_tsv() + small_tsv = make_tsv(rows=30, ys=2) with sync_playwright() as p: browser = p.chromium.launch(headless=not a.headed) print("chromium", browser.version, "headed" if a.headed else "headless", "platform", platform.platform()) - cold, warm = bench_startup(browser, url, a.reps) - first, change, save, edit_run = bench_render(browser, url, a.reps, csv_path) - xlsx = bench_xlsx(browser, url, a.reps, REPO / "tests" / "fixtures" / "multi_sheet.xlsx") - save120, save300 = bench_save_dpi(browser, url, a.reps, csv_path) - latin_first, latin_cached, latin_fetch = bench_latin_font(browser, url, a.reps, csv_path) - copy = bench_copy(browser, url, a.reps, csv_path) - jp_total, jp_after_ready = bench_japanese_font(browser, url, a.reps) + res = {} + if a.only != "new": + cold, warm = bench_startup(browser, url, a.reps) + first, change, save, edit_run = bench_render(browser, url, a.reps, csv_path) + xlsx = bench_xlsx(browser, url, a.reps, REPO / "tests" / "fixtures" / "multi_sheet.xlsx") + save120, save300 = bench_save_dpi(browser, url, a.reps, csv_path) + latin_first, latin_cached, latin_fetch = bench_latin_font(browser, url, a.reps, csv_path) + copy = bench_copy(browser, url, a.reps, csv_path) + jp_total, jp_after_ready = bench_japanese_font(browser, url, a.reps) + res.update({ + "cold_startup_ms": cold, "warm_startup_ms": warm, + "first_plot_ms": first, "change_to_plot_ms": change, + "change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in change], + "save_png_ms": save, + "edit_mode_run_ms": edit_run, + "first_xlsx_plot_ms": xlsx, + # Phase 3 で追加。save_png_ms は既定 300 dpi(Phase 2 までは 120 dpi 固定) + "save_png_120dpi_ms": save120, + "save_png_300dpi_ms": save300, + "latin_font_first_select_to_plot_ms": latin_first, + "latin_font_cached_select_to_plot_ms": latin_cached, + "latin_font_first_select_to_font_ready_ms": latin_fetch, # 参考値 + "copy_to_clipboard_300dpi_ms": copy, + "japanese_font_cold_navigate_to_font_ready_ms": jp_total, + "japanese_font_cold_after_app_ready_ms": jp_after_ready, + }) + if a.only != "old": + paste = bench_paste(browser, url, a.reps, big_tsv, small_tsv) + xlsx_first, sheet_switch = bench_sheet_switch(browser, url, a.reps, xlsx_path) + dt_first, dt_change = bench_datetime(browser, url, a.reps, dt_path) + two_first, two_change = bench_two_files(browser, url, a.reps, csv_path, csv2_path) + res.update({ + # Phase 4 で追加 + **paste, + "xlsx_2sheets_10000_first_plot_ms": xlsx_first, + "xlsx_sheet_switch_to_plot_ms": sheet_switch, + "datetime_10000_first_plot_ms": dt_first, + "datetime_change_to_plot_ms": dt_change, + "datetime_change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in dt_change], + "two_files_first_plot_ms": two_first, + "two_files_change_to_plot_ms": two_change, + "two_files_change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in two_change], + }) browser.close() - res = { - "cold_startup_ms": cold, "warm_startup_ms": warm, - "first_plot_ms": first, "change_to_plot_ms": change, - "change_to_plot_minus_debounce_ms": [c - DEBOUNCE_MS for c in change], - "save_png_ms": save, - "edit_mode_run_ms": edit_run, - "first_xlsx_plot_ms": xlsx, - # Phase 3 で追加。save_png_ms は既定 300 dpi(Phase 2 までは 120 dpi 固定) - "save_png_120dpi_ms": save120, - "save_png_300dpi_ms": save300, - "latin_font_first_select_to_plot_ms": latin_first, - "latin_font_cached_select_to_plot_ms": latin_cached, - "latin_font_first_select_to_font_ready_ms": latin_fetch, # 参考値 - "copy_to_clipboard_300dpi_ms": copy, - "japanese_font_cold_navigate_to_font_ready_ms": jp_total, - "japanese_font_cold_after_app_ready_ms": jp_after_ready, - } for k, v in res.items(): print(fmt(k, v)) if a.json: diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 64a6202..2064cea 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -34,7 +34,7 @@ def load(name="utf8.csv", settings=None): def test_load_file_response_shape(): r = load() assert r["ok"] is True and r["encoding"] == "utf-8" and r["warnings"] == [] - assert r["columns"][0] == {"value": "__idx__0", "label": "時間 [0]"} + assert r["columns"][0] == {"value": "__idx__0", "label": "時間 [0]", "kind": "number"} assert r["preview"]["totalRows"] == 30 @@ -98,7 +98,7 @@ def test_render_returns_generated_code_summary_and_output(): load() r = json.loads(api.render_json(json.dumps(default_settings()))) assert r["ok"] is True and r["output"] == "" - assert r["code"].startswith("# matplotlib GUI が生成したスクリプト") and "pd.read_csv(DATA_FILE" in r["code"] + assert r["code"].startswith("# matplotlib GUI が生成したスクリプト") and "pd.read_csv(\n DATA_FILE," in r["code"] assert 'encoding="utf-8"' in r["code"] and "plt.show()" in r["code"] # 表示用は完全なスクリプト assert r["summary"][0]["lines"] == 1 and r["summary"][0]["xlabel"] == "index" and r["summary"][0]["ylabel"] == "時間 [0]" diff --git a/tests/unit/test_codegen.py b/tests/unit/test_codegen.py index 67f020c..abf5596 100644 --- a/tests/unit/test_codegen.py +++ b/tests/unit/test_codegen.py @@ -355,8 +355,10 @@ def test_x_numeric_strings_are_converted_and_text_x_is_positional(tmp_path): b = Built(tmp_path, df.assign(x=["1", "2", "3", "x"]), None, [S(2, x="__idx__0")]) assert 'x = pd.to_numeric(df.iloc[:, 0], errors="coerce")' in b.script.text b = Built(tmp_path, df, None, [S(2, x="__idx__1")]) - assert 'ax.set_xscale("linear")' in b.script.text - b.summaries() + assert "set_xscale" not in b.script.text # 文字列の X は、目盛にカテゴリの値が出る(Phase 4 で Phase 2 の方式をやめた) + full, auto = b.summaries() + assert full == auto + assert full[0]["xticklabels"] == ["a", "b", "c", "d"] def test_no_header_file(tmp_path): @@ -443,7 +445,7 @@ def test_xlsx_load_section(tmp_path): raw["series"] = [S(1, x="__idx__0")] settings = parse_settings(raw) script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings)) - assert 'pd.read_excel(DATA_FILE, sheet_name="Sheet1", header=0)' in script.text and "openpyxl" in script.text + assert "pd.read_excel(\n DATA_FILE,\n" in script.text and ' sheet_name="Sheet1", # シート\n' in script.text and "openpyxl" in script.text with run_script(script.text, cwd=tmp_path) as r: assert len(lines(r.fig.axes[0])) == 1 @@ -588,7 +590,7 @@ def test_series_blocks_are_separated_by_a_blank_line(tmp_path, data): def test_comments_use_the_same_word_for_tick_marks(tmp_path, data): b = Built(tmp_path, data, None, [S(1, x="__idx__3")]) - assert "set_xscale" in b.script.text and "目盛り" not in b.script.text + assert "目盛は内向き" in b.script.text and "目盛り" not in b.script.text # ---------------------------------------------------------------- Phase 3: figure size, latin fonts, save rc diff --git a/tests/unit/test_datetime.py b/tests/unit/test_datetime.py new file mode 100644 index 0000000..27d9954 --- /dev/null +++ b/tests/unit/test_datetime.py @@ -0,0 +1,622 @@ +"""Phase 4 D5(日時の列)の単体テスト: 認識、読込部と loader の一致、描画計画、生成コード、注入対策。""" +import json +import os +import shutil +import time +from datetime import datetime, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from mplgui import api, loader +from mplgui.codegen import generate_script +from mplgui.dataprep import auto_bar_date_format, plan_plot +from mplgui.errors import UserError +from mplgui.loader import DatetimeColumn, SourceInfo, build_preview, column_options, detect_datetime_columns, load_file +from mplgui.runner import figure_summary, run_script +from mplgui.settings import default_settings, parse_settings + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" + + +def csv(values, header="t", extra=None): + """1 列(values)の CSV を読み込む。""" + text = header + "\n" + "\n".join(values) + "\n" + return load_file(text.encode("utf-8"), "a.csv", "", True) + + +def fmts(result): + return [(c.index, c.format) for c in result.source.datetime_columns] + + +def hourly(fmt, n=12): + base = datetime(2025, 12, 31, 22, 0) + return [(base + timedelta(hours=i)).strftime(fmt) for i in range(n)] + + +# ---------------------------------------------------------------- recognition + + +@pytest.mark.parametrize( + "fmt, value_fmt", + [ + ("%Y-%m-%d", "%Y-%m-%d"), + ("%Y/%m/%d", "%Y/%m/%d"), + ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M"), + ("%Y/%m/%d %H:%M", "%Y/%m/%d %H:%M"), + ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"), + ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S.%f"), + ("%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M"), + ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S"), + ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"), + ], +) +def test_each_format_family_is_recognized(fmt, value_fmt): + r = csv(hourly(value_fmt)) + assert fmts(r) == [(0, fmt)] + assert pd.api.types.is_datetime64_any_dtype(r.df.iloc[:, 0].dtype) + assert r.df.iloc[0, 0] == pd.Timestamp(2025, 12, 31, 22, 0) or "%H" not in fmt + + +def test_japanese_dates_with_and_without_padding(): + r = csv(["2026年1月5日", "2026年1月6日", "2026年12月10日"]) + assert fmts(r) == [(0, "%Y年%m月%d日")] + assert list(r.df.iloc[:, 0]) == [pd.Timestamp(2026, 1, 5), pd.Timestamp(2026, 1, 6), pd.Timestamp(2026, 12, 10)] + + +def test_japanese_date_with_time_is_recognized(): + r = csv(["2026年1月5日 09:30", "2026年1月5日 10:30"]) + assert fmts(r) == [(0, "%Y年%m月%d日 %H:%M")] + + +def test_iso_offsets_same_offset_keep_wall_time(): + r = csv(["2026-01-05T09:00:00+09:00", "2026-01-05T10:00:00+09:00"]) + assert fmts(r) == [(0, "%Y-%m-%dT%H:%M:%S%z")] + spec = r.source.datetime_columns[0] + assert spec.has_tz and not spec.utc + assert list(r.df.iloc[:, 0]) == [pd.Timestamp(2026, 1, 5, 9), pd.Timestamp(2026, 1, 5, 10)] + assert r.df.iloc[:, 0].dt.tz is None and r.warnings == [] + + +def test_z_only_is_utc_wall_time(): + r = csv(["2026-01-05 09:00Z", "2026-01-05 10:00Z"]) + assert fmts(r) == [(0, "%Y-%m-%d %H:%M%z")] + assert list(r.df.iloc[:, 0])[0] == pd.Timestamp(2026, 1, 5, 9) + + +def test_mixed_offsets_become_utc_with_warning(): + r = csv(["2026-01-05T09:00:00Z", "2026-01-05T09:00:00+09:00"], header="混在") + spec = r.source.datetime_columns[0] + assert spec.utc and spec.has_tz + assert list(r.df.iloc[:, 0]) == [pd.Timestamp(2026, 1, 5, 9), pd.Timestamp(2026, 1, 5, 0)] + assert r.warnings == ["列「混在」[0]: タイムゾーンの異なる日時が混在しているため、UTC の時刻にそろえました。"] + + +def test_z_and_plus_zero_are_the_same_offset(): + r = csv(["2026-01-05T09:00:00Z", "2026-01-05T09:00:00+00:00"]) + assert not r.source.datetime_columns[0].utc + + +def test_leading_and_trailing_spaces_are_stripped(): + r = csv([" 2026-01-05 ", "2026-01-06", "2026-01-07 "]) + spec = r.source.datetime_columns[0] + assert spec.strip and r.df.iloc[0, 0] == pd.Timestamp(2026, 1, 5) + r = csv(["2026-01-05", "2026-01-06"]) + assert not r.source.datetime_columns[0].strip + + +def _rows(n_good, n_bad, bad="x"): + return hourly("%Y-%m-%d %H:%M", n_good) + [bad] * n_bad + + +def test_ninety_percent_threshold_boundary(): + def column(n_bad, total=1000): + good = [f"2026-01-{(i % 28) + 1:02d}" for i in range(total - n_bad)] + return good + ["x"] * n_bad + + r = csv(column(100)) # 900 / 1000 = ちょうど 90%: 採用 + assert fmts(r) == [(0, "%Y-%m-%d")] + assert r.warnings == ['列「t」[0]: 日時として読めない値が100件あったため、欠損として扱います(例: "x")。'] + assert fmts(csv(column(101))) == [] # 899 / 1000: 採用しない + + +def test_small_columns_tolerate_one_bad_value_in_ten(): + good = [f"2026-01-{d:02d}" for d in range(1, 10)] + r = csv(good + ["x"]) # 9 / 10: 採用し、1 件は NaT + 警告 + assert fmts(r) == [(0, "%Y-%m-%d")] and pd.isna(r.df.iloc[9, 0]) + assert r.warnings == ['列「t」[0]: 日時として読めない値が1件あったため、欠損として扱います(例: "x")。'] + assert fmts(csv(good[:8] + ["x", "y"])) == [] # 8 / 10: 認識しない + + +def test_bad_values_inside_the_first_100_of_a_long_column(): + good = [f"2026-01-01 {i % 24:02d}:00" for i in range(150)] + values = list(good) + values[10] = values[50] = values[90] = "x" # 先頭 100 個のうち 97%、全体の 98% + r = csv(values) + assert fmts(r) == [(0, "%Y-%m-%d %H:%M")] and "3件" in r.warnings[0] + for i in range(11): # 先頭 100 個の中の読めない値が 11 個: 89% で、書式を選べない + values[i * 8] = "x" + assert fmts(csv(values)) == [] + + +def test_format_with_most_matches_in_sample_wins(): + values = [f"2026-01-{(i % 28) + 1:02d}" for i in range(20)] + r = csv(values) + assert fmts(r) == [(0, "%Y-%m-%d")] + + +def test_blank_values_are_not_counted_and_not_warned(): + r = csv([f"2026-01-{d:02d}" for d in range(1, 10)] + ["", " "]) + assert fmts(r) == [(0, "%Y-%m-%d")] and r.warnings == [] + + +def test_warning_examples_are_capped_at_three_and_distinct(): + values = [f"2026-01-{(i % 28) + 1:02d}" for i in range(100)] + ["a", "a", "b", "c", "d"] + [f"2026-01-{(i % 28) + 1:02d}" for i in range(100)] + r = csv(values) + assert r.warnings == ['列「t」[0]: 日時として読めない値が5件あったため、欠損として扱います(例: "a", "b", "c")。'] + + +@pytest.mark.parametrize( + "values", + [ + ["A-001", "A-002", "A-003"], + ["2024-001", "2024-002", "2024-003"], + ["1.2.3", "1.2.4", "1.3.0"], + ["v2024.1", "v2024.2", "v2024.3"], + ["01/02/2024", "02/02/2024", "03/02/2024"], + ["13:45", "14:45", "15:45"], + ["2024-01", "2024-02", "2024-03"], + ["2024", "2025", "2026"], + ["2024/1", "2024/2", "2024/3"], + ["1月5日", "1月6日", "1月7日"], + ["2024-01-05 (月)", "2024-01-06 (火)", "2024-01-07 (水)"], + ], +) +def test_non_dates_are_not_recognized(values): + r = csv(values) + assert r.source.datetime_columns == () and not pd.api.types.is_datetime64_any_dtype(r.df.iloc[:, 0].dtype) + + +def test_numeric_yyyymmdd_is_not_recognized(): + r = csv(["20240101", "20240102", "20240103"]) + assert r.source.datetime_columns == () + + +def test_numeric_looking_strings_are_not_recognized(): + r = csv(['"20240101"', '"20240102"', "x"]) + assert r.source.datetime_columns == () + + +def test_mixed_formats_in_one_column_are_not_recognized(): + r = csv(["2026-01-05", "2026/01/06", "2026-01-07 10:00", "2026年1月8日"] * 3) + assert r.source.datetime_columns == () + + +def test_no_date_with_non_string_cells_is_recognized(): + df = pd.DataFrame({"a": ["2026-01-05", 3.5, None, pd.Timestamp(2026, 1, 6)]}, dtype=object) + assert detect_datetime_columns(df) == () + + +def test_parse_dates_false_disables_string_recognition(): + text = "t,v\n2026-01-05,1\n2026-01-06,2\n" + r = load_file(text.encode(), "a.csv", "", True, parse_dates=False) + assert r.source.datetime_columns == () and not pd.api.types.is_datetime64_any_dtype(r.df.iloc[:, 0].dtype) + + +def test_excel_datetime_cells_are_datetime_even_without_parse_dates(): + raw = (FIXTURES / "datetime.xlsx").read_bytes() + for flag in (True, False): + r = load_file(raw, "datetime.xlsx", parse_dates=flag) + assert pd.api.types.is_datetime64_any_dtype(r.df.iloc[:, 0].dtype) + assert r.source.datetime_columns == () and r.warnings == [] # 変換の行は要らない + + +def test_excel_string_date_cells_go_through_recognition(tmp_path): + import openpyxl + + wb = openpyxl.Workbook() + ws = wb.active + ws.append(["日付", "値"]) + for i in range(5): + ws.append([f"2026/01/{i + 1:02d}", i]) + path = tmp_path / "s.xlsx" + wb.save(path) + r = load_file(path.read_bytes(), "s.xlsx") + assert fmts(r) == [(0, "%Y/%m/%d")] and pd.api.types.is_datetime64_any_dtype(r.df.iloc[:, 0].dtype) + r = load_file(path.read_bytes(), "s.xlsx", parse_dates=False) + assert r.source.datetime_columns == () + + +def test_header_less_file_gets_recognition_after_rename(): + r = load_file(b"2026-01-05,1\n2026-01-06,2\n", "a.csv", "", False) + assert list(r.df.columns) == ["column_0", "column_1"] and fmts(r) == [(0, "%Y-%m-%d")] + assert r.source.datetime_columns[0].name == "column_0" + + +def test_datetime_fixture_recognition_and_warning(): + r = load_file((FIXTURES / "datetime.csv").read_bytes(), "datetime.csv") + assert fmts(r) == [(0, "%Y/%m/%d %H:%M")] + kinds = [o["kind"] for o in column_options(r.df)] + assert kinds == ["datetime", "text", "number", "number", "number"] # ID(A-001)は文字列、通番は数値 + assert build_preview(r.df)["columnKinds"] == kinds + assert r.warnings == ['列「日時」[0]: 日時として読めない値が1件あったため、欠損として扱います(例: "測定エラー")。'] + col = r.df.iloc[:, 0] + assert col.iloc[0] == pd.Timestamp(2025, 12, 31, 22) and col.iloc[3] == pd.Timestamp(2026, 1, 1, 1) + assert col.isna().sum() == 2 # 読めない値 1 件 + 空欄 1 件 + + +def test_iso_tz_fixture(): + r = load_file((FIXTURES / "datetime_iso_tz.csv").read_bytes(), "datetime_iso_tz.csv") + a, b = r.source.datetime_columns + assert (a.utc, a.has_tz) == (False, True) and (b.utc, b.has_tz) == (True, True) + assert r.df.iloc[0, 0] == pd.Timestamp(2026, 1, 5, 9) # 書かれた時刻のまま + assert r.df.iloc[0, 1] == pd.Timestamp(2026, 1, 5, 9) and r.df.iloc[1, 1] == pd.Timestamp(2026, 1, 5, 1) # UTC にそろえる + assert len(r.warnings) == 1 and "混在" in r.warnings[0] + + +def test_api_load_file_reports_kinds_and_warnings(): + out = json.loads(api.load_file_json("datetime.csv", (FIXTURES / "datetime.csv").read_bytes(), json.dumps(default_settings()))) + assert out["ok"] and out["columns"][0]["kind"] == "datetime" and out["preview"]["columnKinds"][0] == "datetime" + assert any("測定エラー" in w for w in out["warnings"]) + off = default_settings() + off["load"]["parseDates"] = False + out = json.loads(api.load_file_json("datetime.csv", (FIXTURES / "datetime.csv").read_bytes(), json.dumps(off))) + assert out["columns"][0]["kind"] == "text" and out["warnings"] == [] + + +# ---------------------------------------------------------------- generated read section == loader + + +def exec_load(tmp_path, script): + start, end = script.load_lines + code = "import pandas as pd\n" + "\n".join(script.text.split("\n")[start - 1:end]) + ns = {} + cwd = Path.cwd() + os.chdir(tmp_path) + try: + exec(compile(code, "load.py", "exec"), ns) + finally: + os.chdir(cwd) + return ns["df"] + + +def plan_script(loaded, mutate=None, series=None): + raw = default_settings() + raw["series"] = series or [{"x": "", "y": "__idx__1"}] + if mutate: + mutate(raw) + settings = parse_settings(raw) + plan = plan_plot(loaded.df, settings) + return settings, plan, generate_script(settings, loaded.source, plan) + + +@pytest.mark.parametrize("name", ["datetime.csv", "datetime_iso_tz.csv", "dates_ja.csv", "datetime.xlsx"]) +def test_generated_read_section_matches_loader(tmp_path, name): + shutil.copy(FIXTURES / name, tmp_path / name) + loaded = load_file((FIXTURES / name).read_bytes(), name) + y = "__idx__1" if loaded.df.shape[1] > 1 else "__idx__0" + series = [{"x": "", "y": "__idx__2" if name in {"datetime.csv", "datetime_iso_tz.csv"} else y}] + _, _, script = plan_script(loaded, series=series) + pd.testing.assert_frame_equal(exec_load(tmp_path, script), loaded.df) + auto = script.auto_render_text() + assert "to_datetime" not in auto and ("isetitem" not in auto) + + +def test_generated_read_section_lines_for_datetime_fixture(tmp_path): + shutil.copy(FIXTURES / "datetime.csv", tmp_path / "datetime.csv") + loaded = load_file((FIXTURES / "datetime.csv").read_bytes(), "datetime.csv") + _, _, script = plan_script(loaded, series=[{"x": "", "y": "__idx__3"}]) + assert ( + "# 日時の列を日時に変換する(自動で認識した書式。読めない値は NaT(欠損)になる)\n" + 'df.isetitem(0, pd.to_datetime(df.iloc[:, 0], format="%Y/%m/%d %H:%M", errors="coerce")) # 日時 [0]\n' + ) in script.text + start, end = script.load_lines + assert script.text.split("\n")[end - 1].endswith("# 日時 [0]") + + +def test_expression_variants_match_loader(tmp_path): + cases = { + "strip.csv": [" 2026-01-05 ", "2026-01-06", "2026-01-07 "], + "tz.csv": ["2026-01-05T09:00:00+09:00", "2026-01-05T10:00:00+09:00", "2026-01-05T11:00:00+09:00"], + "utc.csv": ["2026-01-05T09:00:00Z", "2026-01-05T09:00:00+09:00", "2026-01-05T09:00:00-05:00"], + "stripmixed.csv": [" 2026-01-05T09:00:00Z", "2026-01-05T09:00:00+09:00 "], + "ja.csv": ["2026年1月5日", "2026年01月06日", "2026年12月7日"], + } + for name, values in cases.items(): + raw = ("t,v\n" + "\n".join(f"{v},{i}" for i, v in enumerate(values)) + "\n").encode("utf-8") + (tmp_path / name).write_bytes(raw) + loaded = load_file(raw, name) + assert loaded.source.datetime_columns, name + _, _, script = plan_script(loaded) + pd.testing.assert_frame_equal(exec_load(tmp_path, script), loaded.df) + + +# ---------------------------------------------------------------- dataprep + + +def dt_frame(n=6, freq="D"): + return pd.DataFrame({"t": pd.date_range("2026-01-01", periods=n, freq=freq), "v": np.arange(n, dtype=float), "w": np.arange(n) * 2.0}) + + +def plan(df, **kw): + raw = default_settings() + raw["series"] = kw.pop("series", [{"x": "__idx__0", "y": "__idx__1"}]) + mutate = kw.pop("mutate", None) + if mutate: + mutate(raw) + return plan_plot(df, parse_settings(raw)) + + +def test_plan_flags_datetime_x(): + p = plan(dt_frame()) + assert p.datetime_x and p.series[0].datetime_x and not p.series[0].categorical_x and not p.series[0].convert_x + assert not plan(dt_frame(), series=[{"x": "", "y": "__idx__1"}]).datetime_x + + +def test_mixed_datetime_and_other_x_is_an_error(): + df = dt_frame() + with pytest.raises(UserError) as e: + plan(df, series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__2", "y": "__idx__1"}]) + assert "同じ図に描くことはできません" in str(e.value) + with pytest.raises(UserError): + plan(df, series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "", "y": "__idx__2"}]) + + +def test_datetime_x_with_log_or_range_is_an_error(): + with pytest.raises(UserError) as e: + plan(dt_frame(), mutate=lambda s: s["axes"]["x"].update(scale="log")) + assert str(e.value) == "X が日時の列のときは、X軸を対数にできません。" and e.value.field == "X軸スケール" + for key in ("min", "max"): + with pytest.raises(UserError) as e: + plan(dt_frame(), mutate=lambda s, k=key: s["axes"]["x"].update(**{k: 3})) + assert str(e.value) == "X が日時の列のときは、X軸の最小値・最大値は指定できません。空欄にしてください。" + assert e.value.field == "X軸の範囲" + + +def test_datetime_y_is_an_error(): + with pytest.raises(UserError) as e: + plan(dt_frame(), series=[{"x": "__idx__1", "y": "__idx__0"}]) + assert str(e.value) == "系列1: Y列(t)は日時の列です。Y列には数値の列を選んでください。" + with pytest.raises(UserError): + plan(dt_frame(), series=[{"x": "", "y": "__idx__0"}], mutate=lambda s: s["plot"].update(type="bar")) + + +def test_bar_datetime_formats(): + def fmt(df, **kw): + return plan(df, mutate=lambda s: (s["plot"].update(type="bar", xColumn="__idx__0"), s["axes"]["x"].update(**kw))).bar_date_format + + assert fmt(dt_frame()) == "%Y-%m-%d" + assert fmt(dt_frame(freq="h")) == "%Y-%m-%d %H:%M" + df = dt_frame() + df["t"] = df["t"] + pd.to_timedelta([0, 1, 2, 3, 4, 5], unit="s") + assert fmt(df) == "%Y-%m-%d %H:%M:%S" + assert fmt(dt_frame(), dateFormat="%m/%d") == "%m/%d" + assert auto_bar_date_format(pd.Series([pd.NaT, pd.Timestamp("2026-01-01")])) == "%Y-%m-%d" + p = plan(dt_frame(), mutate=lambda s: s["plot"].update(type="bar", xColumn="__idx__0")) + assert p.datetime_x and p.series[0].datetime_x + + +def test_thin_categorical_flag_counts_distinct_values_over_25(): + def thin(n): + df = pd.DataFrame({"k": [f"c{i}" for i in range(n)], "v": np.arange(n, dtype=float)}) + return plan(df, series=[{"x": "__idx__0", "y": "__idx__1"}]).thin_categorical_x + + assert not thin(25) and thin(26) + df = pd.DataFrame({"k": [f"c{i % 5}" for i in range(100)], "v": np.arange(100, dtype=float)}) + assert not plan(df, series=[{"x": "__idx__0", "y": "__idx__1"}]).thin_categorical_x # 重複は数えない + assert not plan(dt_frame()).thin_categorical_x + + +# ---------------------------------------------------------------- codegen / run + + +class Run: + def __init__(self, tmp_path, df, mutate=None, series=None): + self.df = df + df.to_csv(tmp_path / "m.csv", index=False) + self.loaded = load_file((tmp_path / "m.csv").read_bytes(), "m.csv") + raw = default_settings() + raw["series"] = series or [{"x": "__idx__0", "y": "__idx__1"}] + if mutate: + mutate(raw) + self.settings = parse_settings(raw) + self.plan = plan_plot(self.loaded.df, self.settings) + self.script = generate_script(self.settings, self.loaded.source, self.plan) + self.dir = tmp_path + + def summaries(self): + with run_script(self.script.text, cwd=self.dir) as r: + full = figure_summary(r.fig) + with run_script(self.script.auto_render_text(), injected={"df": self.loaded.df}) as r: + auto = figure_summary(r.fig) + return full, auto + + +@pytest.fixture +def stamps(): + base = datetime(2025, 12, 31, 20, 0) + n = 10 + return pd.DataFrame({ + "日時": [(base + timedelta(hours=i)).strftime("%Y/%m/%d %H:%M") for i in range(n)], + "v": np.arange(n, dtype=float), + "w": np.arange(n) * 2.0, + }) + + +@pytest.mark.parametrize("plot_type", ["line", "scatter"]) +def test_datetime_axis_script_and_summary(tmp_path, stamps, plot_type): + r = Run(tmp_path, stamps, lambda s: s["plot"].update(type=plot_type)) + text = r.script.text + assert "import matplotlib.dates as mdates" in text and "ticker" not in text + assert "x = df.iloc[:, 0] # 日時の列(読み込み時に日時に変換済み)" in text + assert "date_locator = mdates.AutoDateLocator()" in text + assert "ax.xaxis.set_major_locator(date_locator)" in text + assert "ax.xaxis.set_major_formatter(mdates.ConciseDateFormatter(date_locator))" in text + assert "set_xscale" not in text + full, auto = r.summaries() + assert full == auto + assert "Date" in full[0]["xconverter"] + + +def test_datetime_axis_steps_cover_the_formatter_lines(tmp_path, stamps): + r = Run(tmp_path, stamps) + lines = r.script.text.split("\n") + n = next(i for i, line in enumerate(lines, start=1) if "ConciseDateFormatter" in line) + step = r.script.step_for_line(n) + assert step is not None and "X軸" in step.message + + +def test_year_boundary_is_visible_on_the_axis(tmp_path, stamps): + r = Run(tmp_path, stamps) + with run_script(r.script.text, cwd=tmp_path) as run: + run.fig.canvas.draw() + ax = run.fig.axes[0] + labels = [t.get_text() for t in ax.get_xticklabels()] + offset = ax.xaxis.get_offset_text().get_text() + assert any("2026" in t for t in labels) or "2025" in offset or "2026" in offset + assert any(t for t in labels) # 空でない目盛ラベルが出る + + +def test_explicit_date_format_with_japanese(tmp_path, stamps): + fmt = "%m月%d日 %H時" + r = Run(tmp_path, stamps, lambda s: s["axes"]["x"].update(dateFormat=fmt)) + assert f'ax.xaxis.set_major_formatter(mdates.DateFormatter("{fmt}")) # 日時の表示書式' in r.script.text + assert "ConciseDateFormatter" not in r.script.text + full, auto = r.summaries() + assert full == auto + with run_script(r.script.text, cwd=tmp_path) as run: + run.fig.canvas.draw() + assert any("月" in t.get_text() and "時" in t.get_text() for t in run.fig.axes[0].get_xticklabels()) + + +def test_nat_rows_are_excluded_from_plotting(tmp_path, stamps): + stamps.loc[3, "日時"] = "測定エラー" + stamps = pd.concat([stamps] * 12, ignore_index=True) # 先頭 100 行より後ろに不正値を置くため、行数を増やす + stamps.loc[:99, "日時"] = [(datetime(2025, 12, 31) + timedelta(hours=i)).strftime("%Y/%m/%d %H:%M") for i in range(100)] + r = Run(tmp_path, stamps) + assert r.loaded.warnings + with run_script(r.script.text, cwd=tmp_path) as run: + line = run.fig.axes[0].get_lines()[0] + assert len(line.get_xdata()) == r.loaded.df.iloc[:, 0].notna().sum() - r.loaded.df.iloc[:, 1].isna().sum() + + +def test_datetime_bar_single_and_multi(tmp_path): + shutil.copy(FIXTURES / "dates_ja.csv", tmp_path / "dates_ja.csv") + loaded = load_file((FIXTURES / "dates_ja.csv").read_bytes(), "dates_ja.csv") + for series, fn in (([{"x": "", "y": "__idx__1"}], "single"), ([{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__1"}], "multi")): + raw = default_settings() + raw["plot"].update(type="bar", xColumn="__idx__0") + raw["series"] = series + settings = parse_settings(raw) + plan_ = plan_plot(loaded.df, settings) + script = generate_script(settings, loaded.source, plan_) + assert 'x = df.iloc[:, 0].dt.strftime("%Y-%m-%d") # 日時を文字列にして、棒をカテゴリとして並べる' in script.text + assert "mdates" not in script.text + with run_script(script.text, cwd=tmp_path) as run: + labels = [t.get_text() for t in run.fig.axes[0].get_xticklabels()] + assert labels[0] == "2026-01-05" and len(labels) == 8, fn + with run_script(script.auto_render_text(), injected={"df": loaded.df}) as run: + assert [t.get_text() for t in run.fig.axes[0].get_xticklabels()] == labels + + +def test_datetime_bar_with_custom_format(tmp_path): + shutil.copy(FIXTURES / "dates_ja.csv", tmp_path / "dates_ja.csv") + loaded = load_file((FIXTURES / "dates_ja.csv").read_bytes(), "dates_ja.csv") + raw = default_settings() + raw["plot"].update(type="bar", xColumn="__idx__0") + raw["axes"]["x"]["dateFormat"] = "%m月%d日" + raw["series"] = [{"x": "", "y": "__idx__1"}] + settings = parse_settings(raw) + script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings)) + assert '.dt.strftime("%m月%d日")' in script.text + with run_script(script.text, cwd=tmp_path) as run: + assert [t.get_text() for t in run.fig.axes[0].get_xticklabels()][0] == "01月05日" + + +def test_string_x_has_no_linear_scale_and_thins_only_above_25(tmp_path): + def build(n): + df = pd.DataFrame({"k": [f"c{i}" for i in range(n)], "v": np.arange(n, dtype=float)}) + return Run(tmp_path, df, series=[{"x": "__idx__0", "y": "__idx__1"}]) + + few = build(25) + assert "set_xscale" not in few.script.text and "ticker" not in few.script.text and "MaxNLocator" not in few.script.text + full, auto = few.summaries() + assert full == auto and full[0]["xticklabels"][:2] == ["c0", "c1"] + many = build(60) + assert "import matplotlib.ticker as ticker" in many.script.text + assert "ax.xaxis.set_major_locator(ticker.MaxNLocator(nbins=25, integer=True))" in many.script.text + assert "# X が文字列の列: 目盛に X の値を出す(25個を超えるので間引く)" in many.script.text + full, auto = many.summaries() + assert full == auto and 2 <= len([t for t in full[0]["xticklabels"] if t]) <= 30 + + +def test_ticker_import_absent_for_date_axis_and_numeric(tmp_path, stamps): + assert "ticker" not in Run(tmp_path, stamps).script.text + num = pd.DataFrame({"x": np.arange(5.0), "y": np.arange(5.0)}) + t = Run(tmp_path, num).script.text + assert "mdates" not in t and "ticker" not in t + + +# ---------------------------------------------------------------- injection + + +EVIL_FORMATS = ['%Y"); import os #', "%Y'\n%m", '%d" + __import__("os").system("x") + "', "%Y\\"] + + +@pytest.mark.parametrize("fmt", EVIL_FORMATS) +def test_date_format_cannot_inject_code(tmp_path, stamps, fmt): + raw_ok = True + try: + r = Run(tmp_path, stamps, lambda s: s["axes"]["x"].update(dateFormat=fmt)) + except UserError: + raw_ok = False # settings が弾く場合もある + if raw_ok: + import ast + + tree = ast.parse(r.script.text) + assert not any(isinstance(n, ast.Import) and any(a.name == "os" for a in n.names) for n in ast.walk(tree)) + assert "__import__" not in [n.id for n in ast.walk(tree) if isinstance(n, ast.Name)] + full, auto = r.summaries() + assert full == auto + + +def test_datetime_column_name_cannot_inject_code(tmp_path): + name = 'x"\nimport os # ' + spec = DatetimeColumn(index=0, name=name, format="%Y-%m-%d") + from mplgui.codegen import generate_script as gen + + loaded = load_file(b"a,v\n2026-01-01,1\n2026-01-02,2\n", "a.csv") + source = SourceInfo(filename="a.csv", kind="csv", encoding="utf-8", separator=",", datetime_columns=(spec,)) + settings = parse_settings({**default_settings(), "series": [{"x": "__idx__0", "y": "__idx__1"}]}) + script = gen(settings, source, plan_plot(loaded.df, settings)) + for line in script.text.split("\n"): + assert not line.startswith("import os") + compile(script.text, "plot.py", "exec") + + +# ---------------------------------------------------------------- performance (informational) + + +def test_ten_thousand_rows_load_and_render_is_reasonable(): + n = 10000 + base = datetime(2025, 1, 1) + rng = np.random.default_rng(0) + cols = {"日時": [(base + timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M") for i in range(n)]} + for k in range(5): + cols[f"y{k}"] = rng.normal(size=n) + raw = pd.DataFrame(cols).to_csv(index=False).encode("utf-8") + t0 = time.perf_counter() + out = json.loads(api.load_file_json("big.csv", raw, json.dumps(default_settings()))) + t1 = time.perf_counter() + assert out["ok"] and out["columns"][0]["kind"] == "datetime" + s = default_settings() + s["series"] = [{"x": "__idx__0", "y": f"__idx__{k + 1}"} for k in range(5)] + res = json.loads(api.render_json(json.dumps(s))) + t2 = time.perf_counter() + assert res["ok"] + print(f"\nload_file {t1 - t0:.2f}s, render {t2 - t1:.2f}s") + assert t1 - t0 < 10 and t2 - t1 < 15 diff --git a/tests/unit/test_js_modules.py b/tests/unit/test_js_modules.py index 85e2185..d725dbb 100644 --- a/tests/unit/test_js_modules.py +++ b/tests/unit/test_js_modules.py @@ -45,6 +45,8 @@ def test_new_series_color_is_first_unused_palette_color(tmp_path): out.second = nextSeriesColor(); // #03AF7A(#FF4B00 #005AFF は使用済み) while (getSettings().series.length < PALETTE.length) addSeries(); out.allUsed = new Set(getSettings().series.map((s) => s.color)).size; + out.newSource = getSettings().series[getSettings().series.length - 1].source; // v3: データ元は空 + out.version = getSettings().version; out.cycled = nextSeriesColor(); // すべて使用済み → 系列数で循環 out.cycledExpected = PALETTE[getSettings().series.length % PALETTE.length]; console.log(JSON.stringify(out)); @@ -54,6 +56,7 @@ def test_new_series_color_is_first_unused_palette_color(tmp_path): assert res["first"] == "#FF4B00" assert res["second"] == "#03AF7A" assert res["allUsed"] == 11 + assert res["newSource"] == "" and res["version"] == 3 assert res["cycled"] == res["cycledExpected"] diff --git a/tests/unit/test_load_options.py b/tests/unit/test_load_options.py new file mode 100644 index 0000000..0e5c649 --- /dev/null +++ b/tests/unit/test_load_options.py @@ -0,0 +1,451 @@ +"""Phase 4 のデータ取り込み(D2 シート / D3 ヘッダ前の行 / D4 桁区切り・小数点・コメント)の単体テスト。""" +import json +import os +import shutil +from pathlib import Path + +import pandas as pd +import pytest + +from mplgui import api, loader +from mplgui.codegen import generate_script +from mplgui.dataprep import DECIMAL_HINT, THOUSANDS_HINT, plan_plot +from mplgui.errors import UserError +from mplgui.loader import SourceInfo, build_preview, load_file +from mplgui.settings import default_settings, parse_settings + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" + + +def L(name, delimiter="", has_header=True, **kw): + return load_file((FIXTURES / name).read_bytes(), name, delimiter, has_header, **kw) + + +def csv_bytes(text): + return text.encode("utf-8") + + +# ---------------------------------------------------------------- loader + + +def test_existing_positional_call_still_works(): + assert load_file((FIXTURES / "utf8.csv").read_bytes(), "utf8.csv", "", True).df.shape == (30, 3) + + +def test_skip_lines_csv_and_preamble(): + r = L("preamble.csv", skip_lines=3) + assert list(r.df.columns) == ["時間", "電圧", "電流"] and len(r.df) == 30 + assert r.preamble_total == 3 and len(r.preamble_lines) == 3 and r.preamble_lines[0].startswith("装置") + assert r.source.skip_lines == 3 + p = build_preview(r.df, preamble=(r.preamble_lines, r.preamble_total)) + assert p["preamble"] == {"lines": list(r.preamble_lines), "total": 3} + + +def test_no_skip_lines_gives_no_preamble(): + r = L("utf8.csv") + assert r.preamble_total == 0 and build_preview(r.df)["preamble"] is None + assert build_preview(r.df, preamble=((), 0))["preamble"] is None + + +def test_preamble_lines_are_capped_at_20(): + text = "\n".join(f"memo {i}" for i in range(30)) + "\nx,y\n1,2\n" + r = load_file(csv_bytes(text), "m.csv", "", True, skip_lines=30) + assert len(r.preamble_lines) == 20 and r.preamble_total == 30 + assert build_preview(r.df, preamble=(r.preamble_lines, r.preamble_total))["preamble"]["total"] == 30 + + +def test_preamble_without_skip_lines_is_a_parse_error_with_hint(): + with pytest.raises(UserError) as e: + L("preamble.csv") + assert "ヘッダより前に読み飛ばす行数" in e.value.message and e.value.field == "区切り文字" + + +def test_skip_lines_too_large(): + with pytest.raises(UserError) as e: + L("preamble.csv", skip_lines=500) + assert "ヘッダより前に読み飛ばす行数" in e.value.message + with pytest.raises(UserError) as e: + L("preamble.xlsx", skip_lines=500) + assert "ヘッダより前に読み飛ばす行数" in e.value.message + + +def test_comment_characters(): + r = L("comments.csv", comment="#") + assert list(r.df.columns) == ["x", "y"] and len(r.df) == 10 + assert pd.api.types.is_numeric_dtype(r.df["y"]) and r.df["y"].tolist() == [i * i for i in range(10)] + assert r.source.comment == "#" + + +def test_european_numbers(): + r = L("european.csv", ";", thousands=".", decimal=",") + assert r.df["値"].iloc[0] == 1234.5 and r.df["値"].iloc[-1] == -1234567.5 + assert r.df["比"].iloc[-1] == -0.5 and r.df["比"].iloc[1] == 0.25 + assert r.source.thousands == "." and r.source.decimal == "," + + +def test_thousands_with_negative_and_quotes(): + r = L("thousands.csv", thousands=",") + assert r.df["金額"].tolist()[:2] == [1234, 2468] and r.df["差額"].iloc[0] == -98765.5 + assert pd.api.types.is_numeric_dtype(r.df["金額"]) + + +def test_thousands_vs_decimal_ambiguity(): + # 1.234,5(ヨーロッパ式)と 1,234.5(英米式)は、設定どおりに読まれる + eu = load_file(csv_bytes("v\n1.234,5\n"), "a.csv", ";", True, thousands=".", decimal=",").df["v"][0] + us = load_file(csv_bytes('v\n"1,234.5"\n'), "a.csv", ";", True, thousands=",", decimal=".").df["v"][0] + assert eu == us == 1234.5 + + +def test_empty_trailing_lines_are_ignored(): + r = load_file(csv_bytes("a,b\n1,2\n3,4\n\n\n"), "a.csv", "", True) + assert len(r.df) == 2 + + +def test_thousands_space_with_whitespace_separator_is_an_error(): + with pytest.raises(UserError) as e: + load_file(csv_bytes("a b\n1 2\n"), "a.txt", "", True, thousands=" ") + assert e.value.field == "桁区切り" and "空白" in e.value.message + # 区切りが空白でなければ構わない + r = load_file(csv_bytes("a;b\n1 234;2\n"), "a.txt", ";", True, thousands=" ") + assert r.df["a"][0] == 1234 + + +def test_comment_equal_to_separator_is_an_error(): + with pytest.raises(UserError) as e: + load_file(csv_bytes("a;b\n1;2\n"), "a.csv", ";", True, comment=";") + assert e.value.field == "コメント記号" + + +def test_delimiter_hint_warning(): + r = load_file(csv_bytes("a;b;c\n1;2;3\n"), "a.csv", "", True) + assert r.df.shape[1] == 1 and len(r.warnings) == 1 and "区切り文字" in r.warnings[0] + assert load_file(csv_bytes("a;b;c\n1;2;3\n"), "a.csv", ";", True).warnings == [] + assert load_file(csv_bytes("a\n1\n"), "a.csv", "", True).warnings == [] + tabbed = load_file(csv_bytes("a\tb\n1\t2\n"), "a.csv", "", True) + assert tabbed.warnings + # 説明の行を読み飛ばしたあとの先頭行で判定する + r = load_file(csv_bytes("memo;memo\na;b\n1;2\n"), "a.csv", "", True, skip_lines=1) + assert r.warnings + + +def test_no_header_with_options(): + r = load_file(csv_bytes("memo\n1,2\n3,4\n"), "a.csv", "", False, skip_lines=1) + assert list(r.df.columns) == ["column_0", "column_1"] and len(r.df) == 2 + + +# ---------------------------------------------------------------- xlsx + + +def test_xlsx_sheets_and_default_sheet(): + r = L("multi_sheet.xlsx") + assert r.source.sheets == ("Sheet1", "二枚目") and r.source.sheet_name == "Sheet1" + r2 = L("multi_sheet.xlsx", sheet="二枚目") + assert r2.source.sheet_name == "二枚目" and list(r2.df.columns) == ["x", "y", "z"] + assert r2.source.read_kwargs()["sheet_name"] == "二枚目" + + +def test_xlsx_missing_sheet(): + with pytest.raises(UserError) as e: + L("multi_sheet.xlsx", sheet="ない") + assert e.value.field == "シート" and "「ない」" in e.value.message + + +def test_xlsx_skip_lines_and_preamble(): + r = L("preamble.xlsx", sheet="説明つき", skip_lines=2) + assert list(r.df.columns) == ["t", "v", "w"] and len(r.df) == 15 + assert r.preamble_lines == ("測定条件: 合成データ", "単位: V") and r.preamble_total == 2 + + +def test_xlsx_other_options_are_accepted(): + r = L("multi_sheet.xlsx", thousands=",", decimal=",", comment="#") + assert r.df.shape[0] == 30 + + +def test_xlsx_uses_given_excel_object(): + excel = loader.open_excel((FIXTURES / "multi_sheet.xlsx").read_bytes()) + r = L("multi_sheet.xlsx", sheet="二枚目", excel=excel) + assert r.df.shape == (20, 3) + + +def test_read_kwargs_is_shared_definition(): + src = SourceInfo(filename="a.csv", kind="csv", encoding="utf-8", separator=",", skip_lines=2, thousands=",", decimal=".", comment="#") + assert list(src.read_kwargs()) == ["sep", "header", "skiprows", "thousands", "decimal", "comment", "engine"] + xl = SourceInfo(filename="a.xlsx", kind="xlsx", sheet_name="S", has_header=False) + assert xl.read_kwargs() == {"sheet_name": "S", "header": None, "skiprows": 0, "thousands": None, "decimal": ".", "comment": None} + + +# ---------------------------------------------------------------- generated load section equals the loader + +CASES = [ + ("preamble.csv", "", dict(skip_lines=3)), + ("preamble.csv", "", dict(skip_lines=3, has_header=False)), + ("european.csv", ";", dict(thousands=".", decimal=",")), + ("thousands.csv", "", dict(thousands=",")), + ("comments.csv", "", dict(comment="#")), + ("multi_sheet.xlsx", "", dict(sheet="二枚目")), + ("preamble.xlsx", "", dict(sheet="説明つき", skip_lines=2)), + ("preamble.xlsx", "", dict(sheet="データ", has_header=False)), +] + + +def load_section(script): + start, end = script.load_lines + return "import pandas as pd\n" + "\n".join(script.text.split("\n")[start - 1:end]) + + +def exec_in(tmp_path, code): + ns = {} + cwd = Path.cwd() + os.chdir(tmp_path) + try: + exec(compile(code, "load.py", "exec"), ns) + finally: + os.chdir(cwd) + return ns + + +@pytest.mark.parametrize("name, delimiter, opts", CASES) +def test_generated_load_section_matches_loader(tmp_path, name, delimiter, opts): + opts = dict(opts) + has_header = opts.pop("has_header", True) + shutil.copy(FIXTURES / name, tmp_path / name) + loaded = L(name, delimiter, has_header, **opts) + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}) + script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings)) + ns = exec_in(tmp_path, load_section(script)) + pd.testing.assert_frame_equal(ns["df"], loaded.df) + # 自動描画用の変種では、読込部がすべて空行になる + auto = script.auto_render_text() + assert "read_" not in auto and "DATA_FILE" not in auto and "skiprows" not in auto and "df.columns" not in auto + + +def test_read_section_text_has_every_option(): + loaded = L("european.csv", ";", thousands=".", decimal=",", skip_lines=0, comment="%") + settings = parse_settings(default_settings()) + text = generate_script(settings, loaded.source, plan_plot(loaded.df, parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}))).text + assert ( + 'df = pd.read_csv(\n' + ' DATA_FILE,\n' + ' encoding="utf-8", # 文字コード(自動判定)\n' + ' sep=";", # 区切り文字\n' + ' header=0, # 先頭行をヘッダにする(None: ヘッダなし)\n' + ' skiprows=0, # ヘッダより前に読み飛ばす行数\n' + ' thousands=".", # 桁区切り(None: なし)\n' + ' decimal=",", # 小数点\n' + ' comment="%", # コメント記号(None: なし)。この記号から行末までを読まない\n' + ' engine="python",\n' + ')\n' + ) in text + xl = L("multi_sheet.xlsx", sheet="二枚目") + text = generate_script(settings, xl.source, plan_plot(xl.df, parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}))).text + assert "# ファイル内のシート: Sheet1, 二枚目\n" in text + assert 'df = pd.read_excel(\n DATA_FILE,\n sheet_name="二枚目", # シート\n' in text + assert " thousands=None, # 桁区切り(文字列のセルだけに効く。None: なし)\n" in text + assert " decimal=\".\", # 小数点(文字列のセルだけに効く)\n" in text + + +# ---------------------------------------------------------------- injection + +HOSTILE_NAMES = ['x"); import os; os.system("echo") #', "a\nimport os\nos.system('echo')\n", "s\r\nraise SystemExit", "'''\nraise SystemExit"] + + +@pytest.mark.parametrize("hostile", HOSTILE_NAMES) +def test_hostile_sheet_and_file_names_do_not_inject(hostile): + import ast + + src = SourceInfo( + filename=hostile + ".xlsx", kind="xlsx", sheet_name=hostile, sheets=(hostile, "b\nc"), has_header=False, + thousands=",", comment="#", + ) + df = pd.DataFrame({"a": [1.0, 2.0]}) + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__0"}]}) + script = generate_script(settings, src, plan_plot(df, settings)) + tree = ast.parse(script.text) + calls = {n.func.attr if isinstance(n.func, ast.Attribute) else getattr(n.func, "id", "") for n in ast.walk(tree) if isinstance(n, ast.Call)} + assert "system" not in calls and "__import__" not in calls + assert not any(isinstance(n, (ast.Import, ast.ImportFrom)) and any(a.name == "os" for a in getattr(n, "names", [])) for n in ast.walk(tree)) + assert "SystemExit" not in {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)} + # 読込部のコメントは1行に収まる + start, end = script.load_lines + for line in script.text.split("\n")[start - 1:end]: + assert "\r" not in line + + +@pytest.mark.parametrize("comment", ["#", "%", ";", "\\", "'", "x"]) +def test_comment_character_is_a_literal(comment): + src = SourceInfo(filename="a.csv", kind="csv", encoding="utf-8", separator=",", comment=comment) + df = pd.DataFrame({"a": [1.0, 2.0]}) + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__0"}]}) + script = generate_script(settings, src, plan_plot(df, settings)) + compile(script.text, "plot.py", "exec") + start, end = script.load_lines + section = "\n".join(script.text.split("\n")[start - 1:end]) + ns = {} + code = section.replace("df = pd.read_csv(", "kw = dict(", 1).replace(" DATA_FILE,\n", "") + exec("import pandas as pd\n" + code, ns) + assert ns["kw"]["comment"] == comment + + +# ---------------------------------------------------------------- dataprep hints + + +def _plan_warning(text, **load): + raw = default_settings() + raw["load"].update(load) + raw["series"] = [{"x": "", "y": "__idx__1"}] + settings = parse_settings(raw) + df = pd.DataFrame({"a": [1, 2, 3, 4], "b": text}) + return plan_plot(df, settings).warnings + + +def test_thousands_hint(): + w = _plan_warning(["1,234", "2,345", "5", "6"]) + assert w and THOUSANDS_HINT in w[0]["message"] + w = _plan_warning(["-1,234,567.5", "2", "5", "6"]) + assert w and THOUSANDS_HINT in w[0]["message"] + # すでにカンマを桁区切りにしているなら案内しない + assert not any(THOUSANDS_HINT in x["message"] for x in _plan_warning(["1,234x", "2", "5", "6"], thousands=",")) + + +def test_decimal_hint(): + w = _plan_warning(["1,5", "2", "5", "6"]) + assert w and DECIMAL_HINT in w[0]["message"] and THOUSANDS_HINT not in w[0]["message"] + w = _plan_warning(["1.234,5", "2", "5", "6"]) + assert w and DECIMAL_HINT in w[0]["message"] + assert not any(DECIMAL_HINT in x["message"] for x in _plan_warning(["1,5x", "2", "5", "6"], decimal=",")) + + +def test_no_hint_for_ordinary_text(): + w = _plan_warning(["abc", "2", "5", "6"]) + assert w and "桁区切り" not in w[0]["message"] and "小数点" not in w[0]["message"] + + +# ---------------------------------------------------------------- api + + +@pytest.fixture(autouse=True) +def fresh_session(): + api.SESSION.forget() + api.SESSION.workdir = None + yield + api.SESSION.forget() + api.SESSION.workdir = None + + +def api_load(name, **load): + s = default_settings() + sheet = load.pop("sheet", None) + s["load"].update(load) + if sheet is not None: + s["load"]["files"] = [{"id": "d1", "name": name, "sheet": sheet}] + return json.loads(api.load_file_json(name, (FIXTURES / name).read_bytes(), json.dumps(s))) + + +def test_api_sheets_and_sheet(): + r = api_load("multi_sheet.xlsx") + assert r["ok"] and r["sheets"] == ["Sheet1", "二枚目"] and r["sheet"] == "Sheet1" + r = api_load("multi_sheet.xlsx", sheet="二枚目") + assert r["sheet"] == "二枚目" and [c["label"] for c in r["columns"]] == ["x [0]", "y [1]", "z [2]"] + r = api_load("utf8.csv") + assert r["sheets"] == [] and r["sheet"] is None and r["preview"]["preamble"] is None + + +def test_api_missing_sheet_is_an_error(): + r = api_load("multi_sheet.xlsx", sheet="ない") + assert r["ok"] is False and r["error"]["field"] == "シート" + + +def test_api_preamble_and_warnings(): + r = api_load("preamble.csv", skipLines=3) + assert r["ok"] and r["preview"]["preamble"]["total"] == 3 and r["preview"]["preamble"]["lines"][0].startswith("装置") + r = api_load("preamble.csv") + assert r["ok"] is False and "ヘッダより前に読み飛ばす行数" in r["error"]["message"] + r = json.loads(api.load_file_json("a.csv", b"a;b\n1;2\n", json.dumps(default_settings()))) + assert r["ok"] and len(r["warnings"]) == 1 and "区切り文字" in r["warnings"][0] + + +def test_api_load_options_reach_the_script(): + api_load("european.csv", delimiter=";", thousands=".", decimal=",") + s = default_settings() + s["load"].update(delimiter=";", thousands=".", decimal=",") + s["series"] = [{"x": "", "y": "__idx__1"}] + r = json.loads(api.render_json(json.dumps(s))) + assert r["ok"] and 'thousands=".",' in r["code"] and 'decimal=",",' in r["code"] and not r["warnings"] + + +def test_api_reuses_excel_object_for_same_bytes(monkeypatch): + opened = [] + real = loader.open_excel + monkeypatch.setattr(loader, "open_excel", lambda raw: opened.append(1) or real(raw)) + api_load("multi_sheet.xlsx") + api_load("multi_sheet.xlsx", sheet="二枚目") + api_load("multi_sheet.xlsx", sheet="Sheet1") + assert len(opened) == 1 + first = api.SESSION.excel + # 別のファイルを読むとキャッシュを捨てる + api_load("utf8.csv") + assert api.SESSION.excel is None and first is not None + api_load("multi_sheet.xlsx") + assert len(opened) == 2 + api.SESSION.forget() + assert api.SESSION.excel is None + api_load("preamble.xlsx") + assert len(opened) == 3 + + +def test_api_excel_cache_survives_failed_load(): + api_load("multi_sheet.xlsx") + excel = api.SESSION.excel + r = api_load("multi_sheet.xlsx", sheet="ない") + assert r["ok"] is False and api.SESSION.excel is excel + assert api_load("multi_sheet.xlsx", sheet="二枚目")["ok"] + assert api.SESSION.excel is excel + + +def test_settings_file_id_rejects_trailing_newline(): + raw = default_settings() + raw["load"]["files"] = [{"id": "d1\n", "name": "a.csv", "sheet": ""}] + with pytest.raises(UserError): + parse_settings(raw) + + +@pytest.mark.parametrize("name, delimiter, opts", CASES[:7]) +def test_full_script_and_auto_render_agree(tmp_path, name, delimiter, opts): + from mplgui.runner import figure_summary, run_script + + opts = dict(opts) + has_header = opts.pop("has_header", True) + shutil.copy(FIXTURES / name, tmp_path / name) + loaded = L(name, delimiter, has_header, **opts) + settings = parse_settings({**default_settings(), "series": [{"x": "__idx__0", "y": "__idx__1"}]}) + script = generate_script(settings, loaded.source, plan_plot(loaded.df, settings)) + with run_script(script.text, cwd=tmp_path) as run: + full = figure_summary(run.fig) + with run_script(script.auto_render_text(), injected={"df": loaded.df}) as run: + auto = figure_summary(run.fig) + assert full == auto + + +def test_decimal_equal_to_separator_warns_and_reads_quoted_values(): + r = load_file(csv_bytes('a,b\n"1,5",2\n"2,5",3\n'), "d.csv", "", True, decimal=",") + assert list(r.df["a"]) == [1.5, 2.5] + assert any("小数点と区切り文字が同じ記号(,)です" in w for w in r.warnings) + # 区切り文字が違えば警告しない + ok = load_file(csv_bytes("a;b\n1,5;2\n"), "d.csv", ";", True, decimal=",") + assert not any("小数点と区切り文字" in w for w in ok.warnings) + + +def test_thousands_equal_to_separator_is_allowed_silently(): + r = load_file(csv_bytes('a,b\n"1,234",2\n'), "t.csv", "", True, thousands=",") + assert list(r.df["a"]) == [1234] + assert not any("区切り文字が同じ" in w for w in r.warnings) + + +def test_preamble_and_first_data_line_split_only_on_cr_lf(): + text = "memo
line\x0cform\nx;y\n1;2\n" + r = load_file(csv_bytes(text), "p.csv", ";", True, skip_lines=1) + assert r.preamble_lines == ("memo
line\x0cform",) + assert list(r.df.columns) == ["x", "y"] + assert loader._first_data_line("a
b;c\nx\n", 0, "") == "a
b;c" diff --git a/tests/unit/test_loader.py b/tests/unit/test_loader.py index 643aabb..d949f5b 100644 --- a/tests/unit/test_loader.py +++ b/tests/unit/test_loader.py @@ -157,7 +157,8 @@ def test_non_numeric_fixture_loads_as_text_columns(): def test_preview_payload_shape(): df = _load("utf8.csv").df p = build_preview(df) - assert set(p) == {"columns", "rows", "totalRows", "totalColumns", "limit", "truncated"} + assert set(p) == {"columns", "rows", "totalRows", "totalColumns", "limit", "truncated", "preamble", "columnKinds"} + assert p["preamble"] is None assert p["columns"] == ["時間", "電圧", "電流"] assert p["totalRows"] == 30 and p["totalColumns"] == 3 and p["truncated"] is False assert len(p["rows"]) == 30 and all(len(r) == 3 for r in p["rows"]) @@ -205,6 +206,16 @@ def test_decode_delimiter_invalid_escape_still_japanese_error(): assert info.value.field == "区切り文字" and not info.value.message.isascii() +def test_decode_delimiter_unknown_escape_is_kept_without_warning(): + # 「\\d」のような未定義のエスケープは文字のまま残す。出る DeprecationWarning は loader の中で無視する(漏らさない) + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert decode_delimiter("\\d", ".txt") == "\\d" + assert decode_delimiter("a\\q\\t", ".txt") == "a\\q\t" + + def test_ideographic_comma_delimited_file_loads(): data = "時間、電圧、電流\n1、2、3\n4、5、6\n".encode("utf-8") loaded = load_file(data, "comma.txt", "、", True) diff --git a/tests/unit/test_multi_source.py b/tests/unit/test_multi_source.py new file mode 100644 index 0000000..e8762a5 --- /dev/null +++ b/tests/unit/test_multi_source.py @@ -0,0 +1,375 @@ +"""Phase 4 D6(複数ファイル)の単体テスト: Session、データ元の解決、描画計画、生成コード、注入対策。""" +import json +import shutil +from pathlib import Path + +import pandas as pd +import pytest + +from mplgui import api +from mplgui.codegen import generate_script +from mplgui.dataprep import plan_plot +from mplgui.errors import UserError +from mplgui.loader import load_file +from mplgui.runner import figure_summary, run_script +from mplgui.settings import default_settings, parse_settings + +pytestmark = pytest.mark.unit + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" + + +@pytest.fixture(autouse=True) +def fresh_session(tmp_path): + api.SESSION.forget() + api.SESSION.workdir = tmp_path / "work" + api.SESSION.workdir.mkdir() + yield + api.SESSION.forget() + api.SESSION.workdir = None + + +def files_settings(*entries, series=None, **plot): + s = default_settings() + s["load"]["files"] = [{"id": i, "name": n, "sheet": sh} for i, n, sh in entries] + s["series"] = series or [{"x": "", "y": "__idx__1"}] + s["plot"].update(plot) + return s + + +def load(name, sid, settings, data=None, pasted=False): + raw = data if data is not None else (FIXTURES / name).read_bytes() + return json.loads(api.load_file_json(name, raw, json.dumps(settings), sid, pasted)) + + +TWO = (("d1", "utf8.csv", ""), ("d2", "growth.csv", "")) + + +def load_two(settings): + assert load("utf8.csv", "d1", settings)["ok"] + assert load("growth.csv", "d2", settings)["ok"] + + +# ---------------------------------------------------------------- Session + + +def test_session_add_replace_remove_clear_and_workfolder(): + s = files_settings(*TWO) + load_two(s) + work = api.SESSION.workdir + assert set(api.SESSION.sources) == {"d1", "d2"} + assert (work / "utf8.csv").exists() and (work / "growth.csv").exists() + # 同じ id を別のファイルで置き換える: 古い作業フォルダのファイルは消え、d2 は残る + s2 = files_settings(("d1", "tab.txt", ""), ("d2", "growth.csv", "")) + assert load("tab.txt", "d1", s2)["ok"] + assert not (work / "utf8.csv").exists() and (work / "tab.txt").exists() and (work / "growth.csv").exists() + assert api.SESSION.sources["d1"].name == "tab.txt" and "d2" in api.SESSION.sources + assert json.loads(api.remove_source_json("d2")) == {"ok": True} + assert set(api.SESSION.sources) == {"d1"} and not (work / "growth.csv").exists() + assert json.loads(api.remove_source_json("zzz")) == {"ok": True} # 無い id でも失敗しない + load_two(s) + assert json.loads(api.clear_sources_json()) == {"ok": True} + assert api.SESSION.sources == {} and list(work.iterdir()) == [] + + +def test_failure_of_one_source_keeps_others(): + s = files_settings(*TWO) + load_two(s) + r = json.loads(api.load_file_json("memo.json", b"{}", json.dumps(s), "d2")) + assert r["ok"] is False + assert set(api.SESSION.sources) == {"d1"} + + +def test_same_name_in_two_sources_keeps_the_file_until_both_removed(): + s = files_settings(("d1", "utf8.csv", ""), ("d2", "utf8.csv", "")) + load("utf8.csv", "d1", s) + load("utf8.csv", "d2", s) + api.remove_source_json("d1") + assert (api.SESSION.workdir / "utf8.csv").exists() + api.remove_source_json("d2") + assert not (api.SESSION.workdir / "utf8.csv").exists() + + +def test_excel_cache_is_per_source(): + s = files_settings(("d1", "multi_sheet.xlsx", ""), ("d2", "multi_sheet.xlsx", "二枚目")) + load("multi_sheet.xlsx", "d1", s) + load("multi_sheet.xlsx", "d2", s) + assert set(api.SESSION.excels) == {"d1", "d2"} + assert api.SESSION.excels["d1"][1] is not api.SESSION.excels["d2"][1] + api.SESSION.forget() + assert api.SESSION.excels == {} + + +# ---------------------------------------------------------------- source resolution + + +def render(settings): + return json.loads(api.render_json(json.dumps(settings))) + + +def test_no_source_loaded_is_the_usual_message(): + r = render(files_settings(*TWO)) + assert r["ok"] is False and "先にファイルを読み込んでください" in r["error"]["message"] + + +def test_empty_source_means_first_file_and_series_use_their_own_source(): + s = files_settings(*TWO, series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__2", "source": "d2"}]) + load_two(s) + r = render(s) + assert r["ok"] and r["seriesCount"] == 2 + # 系列1は utf8.csv の2列目(電圧)、系列2は growth.csv の3列目(線形) + assert "df1.iloc[:, 1]" in r["code"] and "df2.iloc[:, 2]" in r["code"] + assert r["summary"][0]["lines"] == 2 + + +def test_failed_source_used_by_a_series_is_a_user_error(): + s = files_settings(*TWO, series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__2", "source": "d2"}]) + load("utf8.csv", "d1", s) # d2 は読み込めていない + r = render(s) + assert r["ok"] is False + assert r["error"]["message"] == "データ2(growth.csv)を読み込めていません。読込設定やファイルを確認してください。" + assert r["error"]["field"] == "系列2のデータ元" + + +def test_first_source_failed_means_empty_source_series_error(): + s = files_settings(*TWO) + load("growth.csv", "d2", s) + r = render(s) + assert r["ok"] is False and "データ1(utf8.csv)を読み込めていません" in r["error"]["message"] + assert r["error"]["field"] == "系列1のデータ元" + + +def test_without_files_list_the_first_loaded_source_is_used(): + s = default_settings() + load("utf8.csv", "d1", s) + r = render(s) + assert r["ok"] and "DATA_FILE =" in r["code"] and "df = pd.read_csv" in r["code"] + + +# ---------------------------------------------------------------- dataprep + + +def frames(): + return { + "d1": pd.DataFrame({"a": [1.0, 2.0, 3.0, 4.0], "b": [5.0, 6.0, 7.0, 8.0]}), + "d2": pd.DataFrame({"t": [10.0, 20.0, 30.0], "u": [1.0, 2.0, 3.0], "v": [4.0, 5.0, 6.0]}), + } + + +def parsed(**kw): + s = files_settings(("d1", "a.csv", ""), ("d2", "b.csv", ""), **kw.pop("plot", {})) + s.update(kw) + return parse_settings(s) + + +def test_dataprep_skip_rows_per_frame_and_y_auto_per_source(): + settings = parsed( + series=[{"x": "", "y": ""}, {"x": "", "y": "", "source": "d2"}, {"x": "", "y": "", "source": "d2"}], + plot={"skipRows": 1}, + ) + plan = plan_plot(frames(), settings) + assert [s.data_var for s in plan.series] == ["df1", "df2", "df2"] + # Y の自動割り当てはデータ元ごと(d1 は a, d2 は t と u) + assert [(s.data_id, s.y_index) for s in plan.series] == [("d1", 0), ("d2", 0), ("d2", 1)] + assert [src.var for src in plan.sources] == ["df1", "df2"] and plan.multi_source + + +def test_dataprep_skip_rows_error_uses_the_frame_that_is_too_short(): + settings = parsed(series=[{"x": "", "y": "", "source": "d2"}], plot={"skipRows": 3}) + with pytest.raises(UserError): + plan_plot(frames(), settings) + + +def test_dataprep_bar_requires_same_source(): + settings = parsed(series=[{"x": "", "y": ""}, {"x": "", "y": "", "source": "d2"}], plot={"type": "bar"}) + with pytest.raises(UserError) as e: + plan_plot(frames(), settings) + assert e.value.message == "棒グラフでは、すべての系列に同じデータ元を選んでください。" and e.value.field == "データ元" + ok = parsed(series=[{"x": "", "y": "", "source": "d2"}, {"x": "", "y": "", "source": "d2"}], plot={"type": "bar", "xColumn": "__idx__0"}) + plan = plan_plot(frames(), ok) + assert plan.bar_x_index == 0 and {s.data_var for s in plan.series} == {"df2"} + + +def test_dataprep_unknown_and_missing_source_errors(): + settings = parsed(series=[{"x": "", "y": "", "source": "d2"}]) + only_first = {"d1": frames()["d1"]} + with pytest.raises(UserError) as e: + plan_plot(only_first, settings) + assert "データ2(b.csv)を読み込めていません" in e.value.message and e.value.field == "系列1のデータ元" + with pytest.raises(UserError) as e: + plan_plot({}, settings) + assert "先にファイルを読み込んでください" in e.value.message + + +def test_dataprep_datetime_rules_across_series(): + dt = pd.DataFrame({"t": pd.to_datetime(["2025-01-01", "2025-01-02", "2025-01-03"]), "v": [1.0, 2.0, 3.0]}) + data = {"d1": dt, "d2": frames()["d2"]} + settings = parsed(series=[{"x": "__idx__0", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__1", "source": "d2"}]) + with pytest.raises(UserError) as e: + plan_plot(data, settings) + assert "日時" in e.value.message + + +def test_single_dataframe_input_still_works(): + plan = plan_plot(frames()["d1"], parse_settings(default_settings())) + assert plan.series[0].data_var == "df" and not plan.multi_source and plan.sources[0].var == "df" + + +# ---------------------------------------------------------------- codegen + + +def make(settings_raw, names): + """names: {id: ファイル名}。fixtures を tmp に置く前提で、loader の結果から script を作る。""" + settings = parse_settings(settings_raw) + loaded = {sid: load_file((FIXTURES / n).read_bytes(), n, "", True, sheet=sh) for sid, n, sh in + [(i, names[i], next(f["sheet"] for f in settings_raw["load"]["files"] if f["id"] == i)) for i in names]} + plan = plan_plot({k: v.df for k, v in loaded.items()}, settings) + return settings, loaded, plan, generate_script(settings, {k: v.source for k, v in loaded.items()}, plan) + + +def run_both(script, loaded, plan, cwd): + with run_script(script.text, cwd=cwd) as r: + full = figure_summary(r.fig) + injected = {src.var: loaded[src.id].df for src in plan.sources} + with run_script(script.auto_render_text(), injected=injected) as r: + auto = figure_summary(r.fig) + return full, auto + + +def test_two_source_script_names_numbering_and_summary(tmp_path): + raw = files_settings(*TWO, series=[{"x": "", "y": "__idx__1"}, {"x": "__idx__0", "y": "__idx__2", "source": "d2"}]) + settings, loaded, plan, script = make(raw, {"d1": "utf8.csv", "d2": "growth.csv"}) + text = script.text + for name in ("utf8.csv", "growth.csv"): + shutil.copy(FIXTURES / name, tmp_path / name) + assert 'DATA_FILE_1 = "utf8.csv"' in text and 'DATA_FILE_2 = "growth.csv"' in text + assert "df1 = pd.read_csv(" in text and "df2 = pd.read_csv(" in text + assert "# データ1: utf8.csv" in text and "# データ2: growth.csv" in text + assert "df2.iloc[:, 2]" in text and "「utf8.csv」「growth.csv」" in text + assert "DATA_FILE =" not in text + full, auto = run_both(script, loaded, plan, tmp_path) + assert full == auto and full[0]["lines"] == 2 + # 読込部は番号付きの両方の行を空行にする + start, end = script.load_lines + assert all(line == "" for line in script.auto_render_text().split("\n")[start - 1:end]) + + +def test_only_used_sources_are_emitted_and_numbering_is_the_position(tmp_path): + raw = files_settings(*TWO, series=[{"x": "", "y": "__idx__1", "source": "d2"}]) + settings, loaded, plan, script = make(raw, {"d1": "utf8.csv", "d2": "growth.csv"}) + assert "DATA_FILE_2" in script.text and "DATA_FILE_1" not in script.text and "df1" not in script.text + assert "df2.iloc[:, 1]" in script.text + shutil.copy(FIXTURES / "growth.csv", tmp_path / "growth.csv") + full, auto = run_both(script, loaded, plan, tmp_path) + assert full == auto + + +def test_skip_rows_trims_each_used_frame(tmp_path): + raw = files_settings(*TWO, series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__1", "source": "d2"}], skipRows=2) + _, loaded, plan, script = make(raw, {"d1": "utf8.csv", "d2": "growth.csv"}) + assert "df1 = df1.iloc[2:].reset_index(drop=True)" in script.text and "df2 = df2.iloc[2:].reset_index(drop=True)" in script.text + for name in ("utf8.csv", "growth.csv"): + shutil.copy(FIXTURES / name, tmp_path / name) + full, auto = run_both(script, loaded, plan, tmp_path) + assert full == auto + + +def test_read_blocks_reproduce_each_loader_frame(tmp_path): + """読込部だけを実行して、loader の DataFrame と一致する(csv + xlsx の2枚目のシート)。""" + raw = files_settings(("d1", "utf8.csv", ""), ("d2", "multi_sheet.xlsx", "二枚目"), + series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__1", "source": "d2"}]) + _, loaded, plan, script = make(raw, {"d1": "utf8.csv", "d2": "multi_sheet.xlsx"}) + for name in ("utf8.csv", "multi_sheet.xlsx"): + shutil.copy(FIXTURES / name, tmp_path / name) + start, end = script.load_lines + lines = script.text.split("\n")[start - 1:end] + ns: dict = {} + import os + cwd = os.getcwd() + os.chdir(tmp_path) + try: + exec("import pandas as pd\n" + "\n".join(lines), ns) + finally: + os.chdir(cwd) + pd.testing.assert_frame_equal(ns["df1"], loaded["d1"].df) + pd.testing.assert_frame_equal(ns["df2"], loaded["d2"].df) + assert 'sheet_name="二枚目"' in script.text + full, auto = run_both(script, loaded, plan, tmp_path) + assert full == auto + + +def test_pasted_and_file_sources_note_each_one(tmp_path): + pasted = b"a\tb\n1\t2\n3\t4\n5\t7\n" + s = files_settings(("d1", "pasted_data.tsv", ""), ("d2", "growth.csv", ""), + series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__1", "source": "d2"}]) + load("pasted_data.tsv", "d1", s, data=pasted, pasted=True) + load("growth.csv", "d2", s) + r = render(s) + assert r["ok"] + text = r["code"] + assert text.count("# 貼り付けたデータ: ") == 1 and "(貼り付けたデータを保存したもの)" in text + assert "DATA_FILE_1 = \"pasted_data.tsv\"" in text and "DATA_FILE_2 = \"growth.csv\"" in text + + +def test_two_hostile_file_names_cannot_inject(): + evil1 = 'a"\nimport os; os.system("x") # 
.csv' + evil2 = "b'''\nraise SystemExit\n.csv" + s = files_settings(("d1", evil1, ""), ("d2", evil2, ""), + series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__1", "source": "d2"}]) + api.SESSION.workdir = None + data = b"a,b\n1,2\n3,4\n5,7\n" + assert load(evil1, "d1", s, data=data)["ok"] and load(evil2, "d2", s, data=data)["ok"] + settings = parse_settings(s) + plan = plan_plot({sid: src.df for sid, src in api.SESSION.sources.items()}, settings) + script = generate_script(settings, {sid: src.info for sid, src in api.SESSION.sources.items()}, plan) + import ast + tree = ast.parse(script.text) + calls = {n.func.attr for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)} + assert "system" not in calls + assert not any(isinstance(n, ast.Import) and any(a.name == "os" for a in n.names) for n in ast.walk(tree)) + assigned = {t.id: n.value.value for n in ast.walk(tree) if isinstance(n, ast.Assign) and isinstance(n.value, ast.Constant) + for t in n.targets if isinstance(t, ast.Name)} + assert assigned["DATA_FILE_1"] == evil1 and assigned["DATA_FILE_2"] == evil2 + + +def test_single_file_script_is_unchanged_with_one_entry(): + s = files_settings(("d1", "utf8.csv", "")) + load("utf8.csv", "d1", s) + r = render(s) + assert r["ok"] and "DATA_FILE = " in r["code"] and "df = pd.read_csv" in r["code"] and "DATA_FILE_1" not in r["code"] + + +def test_api_injects_dfn_for_each_used_source(): + s = files_settings(*TWO, series=[{"x": "", "y": "__idx__1"}, {"x": "", "y": "__idx__2", "source": "d2"}]) + load_two(s) + r = render(s) + assert r["ok"] and r["warnings"] == [] and r["image"].startswith("data:image/png") + out = json.loads(api.save_json(json.dumps(s))) + assert out["ok"] + + +def test_edit_mode_reads_all_files_from_the_work_folder(): + s = files_settings(*TWO) + load_two(s) + code = ( + "import pandas as pd\nimport matplotlib.pyplot as plt\n" + "a = pd.read_csv('utf8.csv'); b = pd.read_csv('growth.csv')\n" + "fig, ax = plt.subplots(); ax.plot(a.iloc[:, 0], a.iloc[:, 1]); ax.plot(b.iloc[:, 0], b.iloc[:, 1])\n" + ) + r = json.loads(api.render_json(json.dumps(s), code)) + assert r["ok"], r + + +def test_skip_rows_error_names_the_data_source_only_with_several_files(): + s = files_settings(*TWO, series=[{"x": "", "y": "__idx__1", "source": "d1"}, {"x": "", "y": "__idx__1", "source": "d2"}]) + load_two(s) + s["plot"]["skipRows"] = 10_000 + multi = render(s) + assert not multi["ok"] + assert multi["error"]["message"].startswith("データ1(utf8.csv): スキップ行数がデータ行数以上です。") + single = files_settings(("d1", "utf8.csv", "")) + api.SESSION.forget() + load("utf8.csv", "d1", single) + single["plot"]["skipRows"] = 10_000 + one = render(single) + assert one["error"]["message"].startswith("スキップ行数がデータ行数以上です。") diff --git a/tests/unit/test_paste_tsv.py b/tests/unit/test_paste_tsv.py new file mode 100644 index 0000000..f56ccc6 --- /dev/null +++ b/tests/unit/test_paste_tsv.py @@ -0,0 +1,108 @@ +"""D1: .tsv の読込と、貼り付けたデータ(pasted_data.tsv)の扱い。""" +import json +import os +import tempfile +from pathlib import Path + +import pandas as pd +import pytest + +from mplgui import api +from mplgui.codegen import generate_script +from mplgui.dataprep import plan_plot +from mplgui.errors import UserError +from mplgui.loader import SourceInfo, load_file +from mplgui.settings import default_settings, parse_settings + +TSV = "時間\t電圧\r\n0\t1.5\r\n1\t2.5\r\n2\t3.5\r\n".replace("\r\n", "\n").encode("utf-8") + + +def test_tsv_default_delimiter_is_tab(): + r = load_file(TSV, "a.tsv", "", True) + assert r.df.shape == (3, 2) and r.source.kind == "tsv" and r.source.separator == "\t" + assert list(r.df.columns) == ["時間", "電圧"] + + +def test_tsv_values_with_spaces_stay_in_one_cell(): + r = load_file("名前\t値\nA B\t1\nC D\t2\n".encode(), "a.tsv", "", True) + assert list(r.df.iloc[:, 0]) == ["A B", "C D"] + + +def test_tsv_explicit_delimiter_overrides(): + r = load_file(b"a;b\n1;2\n", "a.tsv", ";", True) + assert r.df.shape == (1, 2) and r.source.separator == ";" + + +def test_unsupported_extension_message_mentions_tsv(): + with pytest.raises(UserError) as e: + load_file(b"x", "a.json", "", True) + assert "tsv" in e.value.message + + +def _script(source, df): + settings = parse_settings({**default_settings(), "series": [{"x": "", "y": "__idx__1"}]}) + return generate_script(settings, source, plan_plot(df, settings)) + + +def test_pasted_source_adds_comment_and_usage(): + r = load_file(TSV, "pasted_data.tsv", "", True) + pasted = SourceInfo(**{**r.source.__dict__, "pasted": True}) + text = _script(pasted, r.df).text + assert "# 貼り付けたデータ: 「データ読み込み」の「貼り付けたデータを保存」で pasted_data.tsv を保存し、このスクリプトと同じフォルダに置いてください。" in text + assert "貼り付けたデータを保存した「pasted_data.tsv」" in text + assert "# 形式: TSV(.tsv)" in text and r'sep="\t"' in text + plain = _script(r.source, r.df).text + assert "貼り付けたデータ" not in plain + + +def test_pasted_comment_is_safe_with_hostile_filename(): + r = load_file(TSV, "x.tsv", "", True) + src = SourceInfo(**{**r.source.__dict__, "filename": "a\nimport os\nos.system('x').tsv", "pasted": True}) + text = _script(src, r.df).text + compile(text, "s.py", "exec") + assert "\nimport os\nos" not in text + + +def test_pasted_read_section_reproduces_loader(tmp_path): + r = load_file(TSV, "pasted_data.tsv", "", True) + src = SourceInfo(**{**r.source.__dict__, "pasted": True}) + script = _script(src, r.df) + (tmp_path / "pasted_data.tsv").write_bytes(TSV) + start, end = script.load_lines + code = "import pandas as pd\n" + "\n".join(script.text.split("\n")[start - 1:end]) + ns, cwd = {}, Path.cwd() + os.chdir(tmp_path) + try: + exec(compile(code, "load.py", "exec"), ns) + finally: + os.chdir(cwd) + pd.testing.assert_frame_equal(ns["df"], r.df) + + +@pytest.fixture +def session(): + api.SESSION.forget() + with tempfile.TemporaryDirectory() as d: + api.SESSION.workdir = Path(d) + yield api.SESSION + api.SESSION.forget() + api.SESSION.workdir = None + + +def _load(pasted): + s = {"version": 3, "load": default_settings()["load"]} + return json.loads(api.load_file_json("pasted_data.tsv", TSV, json.dumps(s), "d1", pasted)) + + +def test_api_pasted_load_marks_source_and_writes_file(session): + r = _load(True) + assert r["ok"] and r["encoding"] == "utf-8" and len(r["columns"]) == 2 + assert session.source.pasted is True and session.source.kind == "tsv" + assert (session.workdir / "pasted_data.tsv").read_bytes() == TSV + code = json.loads(api.script_json(json.dumps(default_settings())))["code"] + assert "貼り付けたデータ" in code + + +def test_api_not_pasted_by_default(session): + r = _load(False) + assert r["ok"] and session.source.pasted is False diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index b03649e..6e9cb30 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -18,7 +18,7 @@ def test_defaults_are_complete_and_parse(): d = default_settings() - assert d["version"] == SCHEMA_VERSION == 2 + assert d["version"] == SCHEMA_VERSION == 3 assert set(d) == {"version", "load", "plot", "axes", "series", "save"} assert set(d["axes"]) == {"x", "y", "y2"} s = parse_settings(d) @@ -36,7 +36,7 @@ def test_default_settings_returns_fresh_objects(): def test_missing_keys_are_filled_from_defaults(): - s = parse_settings({"version": 2, "plot": {"title": " T "}, "series": [{"y": "__idx__2"}]}) + s = parse_settings({"version": 3, "plot": {"title": " T "}, "series": [{"y": "__idx__2"}]}) assert s.plot.title == "T" # 前後の空白は取り除く assert s.plot.font_size == 15 and s.plot.legend_location == "best" assert s.series[0].y == "__idx__2" and s.series[0].line_width == 2 and s.series[0].id == "s1" @@ -143,7 +143,7 @@ def test_unknown_version_rejected(): raw = default_settings() del raw["version"] parse_settings(raw) # version 欠落は現行版として扱う - for bad in (3, 0, "1", None, True, "2"): + for bad in (4, 0, "1", None, True, "2", "3"): raw = default_settings() raw["version"] = bad with pytest.raises(UserError) as info: @@ -305,6 +305,15 @@ def _v1(): for key in ("latinFont", "palette"): del raw["plot"][key] raw["save"] = {"filename": "x", "format": "jpg", "transparent": False} + return _strip_v3(raw) + + +def _strip_v3(raw): + """v3 で足した項目を取り除く(v1 / v2 の設定の形にする)。""" + raw["load"] = {"delimiter": "", "hasHeader": True} + del raw["axes"]["x"]["dateFormat"] + for item in raw["series"]: + del item["source"] return raw @@ -313,10 +322,10 @@ def test_migrate_v1_to_v2(): before = copy.deepcopy(raw) out = migrate_settings(raw) assert raw == before # 引数は変更しない - assert out["version"] == 2 and out["save"]["dpi"] == 120 # v1 は 120 dpi で保存していた + assert out["version"] == 3 and out["save"]["dpi"] == 120 and out["load"]["parseDates"] is False # v1 は 120 dpi で保存していた assert out["save"]["filename"] == "x" s = parse_settings(raw) - assert s.version == 2 and s.save.dpi == 120 and s.save.format == "jpg" + assert s.version == 3 and s.save.dpi == 120 and s.save.format == "jpg" and s.load.parse_dates is False assert s.plot.figure.unit == "in" and s.plot.figure.width == 7 assert s.plot.latin_font == "default" and s.plot.palette == "ud" and s.save.svg_text == "path" @@ -335,12 +344,159 @@ def test_migrate_v1_through_save_and_load_parsers(): assert parse_load_settings(_v1()).has_header is True -def test_migrate_v2_passthrough_and_unknown_versions(): +def test_migrate_v3_passthrough_and_unknown_versions(): raw = default_settings() out = migrate_settings(raw) assert out == raw and out is not raw assert migrate_settings({}) == {} # バージョンの欠落は現行版として扱う(何も足さない) - for bad in (3, 0, "2", True, None): + for bad in (4, 0, "2", "3", True, None): with pytest.raises(UserError) as info: migrate_settings({"version": bad}) assert "バージョン" in info.value.message + + +# ---------------------------------------------------------------- Phase 4: schema version 3 + + +def _load(**kw): + raw = default_settings() + raw["load"].update(kw) + return raw + + +def _err(raw, parser=parse_settings): + with pytest.raises(UserError) as info: + parser(raw) + return info.value + + +def test_v3_defaults(): + d = default_settings() + assert d["load"] == { + "delimiter": "", "hasHeader": True, "skipLines": 0, "thousands": "", "decimal": ".", + "comment": "", "parseDates": True, "files": [], + } + assert d["axes"]["x"]["dateFormat"] == "" and "dateFormat" not in d["axes"]["y"] and "dateFormat" not in d["axes"]["y2"] + assert d["series"][0]["source"] == "" + s = parse_settings(d) + assert s.load.skip_lines == 0 and s.load.thousands == "" and s.load.decimal == "." and s.load.comment == "" + assert s.load.parse_dates is True and s.load.files == () and s.axes.x.date_format == "" and s.series[0].source == "" + assert s.axes.y.date_format == "" and s.axes.y2.date_format == "" + + +def test_v3_valid_load_values(): + raw = _load(skipLines="3", thousands="'", decimal=",", comment="#", parseDates=False, + files=[{"id": "d1", "name": "a.csv", "sheet": ""}, {"id": "d12", "name": "b.xlsx", "sheet": "S2"}]) + s = parse_load_settings(raw) + assert s.skip_lines == 3 and s.thousands == "'" and s.decimal == "," and s.comment == "#" and s.parse_dates is False + assert [(f.id, f.name, f.sheet) for f in s.files] == [("d1", "a.csv", ""), ("d12", "b.xlsx", "S2")] + for t in ("", ",", ".", " ", "'"): + assert parse_load_settings(_load(thousands=t, decimal="." if t != "." else ",")).thousands == t + assert parse_load_settings(_load(skipLines=1_000_000)).skip_lines == 1_000_000 + assert parse_load_settings(_load(skipLines="")).skip_lines == 0 + + +@pytest.mark.parametrize("parser", [parse_settings, parse_load_settings]) +def test_v3_invalid_load_values(parser): + label = "ヘッダより前に読み飛ばす行数" + for bad in (-1, 1.5, "x"): + e = _err(_load(skipLines=bad), parser) + assert e.field == label and e.message == f"「{label}」は0以上の整数で入力してください。" + assert _err(_load(skipLines=1_000_001), parser).field == label + assert _err(_load(thousands="x"), parser).field == "桁区切り" + assert _err(_load(decimal=";"), parser).field == "小数点" + e = _err(_load(thousands=",", decimal=","), parser) + assert e.field == "桁区切り" and e.message == "「桁区切り」と「小数点」に同じ記号は使えません。" + assert _err(_load(comment="##"), parser).message == "「コメント記号」は1文字で指定してください(例: #)。" + for bad in ('"', " ", "\t"): + e = _err(_load(comment=bad), parser) + assert e.field == "コメント記号" and e.message == "「コメント記号」に \" や空白は使えません。" + e = _err(_load(comment=",", thousands=","), parser) + assert e.message == "「コメント記号」に、桁区切り・小数点と同じ記号は使えません。" and e.field == "コメント記号" + assert _err(_load(comment="."), parser).field == "コメント記号" # 小数点の既定値と同じ + assert _err(_load(comment=5), parser).field == "コメント記号" + assert _err(_load(parseDates="yes"), parser).field == "日時の列を自動で認識する" + + +@pytest.mark.parametrize("parser", [parse_settings, parse_load_settings]) +def test_v3_invalid_files(parser): + assert _err(_load(files="d1"), parser).field == "読み込むファイル" + assert _err(_load(files=["d1"]), parser).field == "読み込むファイル" + for bad_id in ("x1", "d", "d12345", "D1", 1, None): + assert _err(_load(files=[{"id": bad_id, "name": "a", "sheet": ""}]), parser).field == "読み込むファイル" + dup = [{"id": "d1", "name": "a", "sheet": ""}, {"id": "d1", "name": "b", "sheet": ""}] + assert _err(_load(files=dup), parser).field == "読み込むファイル" + ten = [{"id": f"d{i}", "name": "a", "sheet": ""} for i in range(1, 11)] + assert len(parse_load_settings(_load(files=ten)).files) == 10 + eleven = ten + [{"id": "d11", "name": "a", "sheet": ""}] + assert _err(_load(files=eleven), parser).message == "読み込めるファイルは10個までです。" + assert _err(_load(files=[{"id": "d1", "name": 1, "sheet": ""}]), parser).field == "ファイル名" + + +def test_v3_series_source(): + raw = _load(files=[{"id": "d1", "name": "a.csv", "sheet": ""}, {"id": "d2", "name": "b.csv", "sheet": ""}]) + raw["series"] = [{"y": "__idx__1", "source": "d2"}, {"y": "__idx__1"}] + s = parse_settings(raw) + assert [x.source for x in s.series] == ["d2", ""] + raw["series"].append({"y": "__idx__1", "source": "d9"}) + e = _err(raw) + assert e.message == "系列3のデータ元が見つかりません。データ元を選び直してください。" and e.field == "系列3のデータ元" + raw = default_settings() + raw["series"][0]["source"] = "d1" # files が空 + assert _err(raw).field == "系列1のデータ元" + raw["series"][0]["source"] = 3 + assert _err(raw).field == "データ元" + + +def test_v3_date_format(): + for ok in ("", "%Y/%m/%d", "%m月%d日", "%H:%M", "%Y-%m-%d %H:%M:%S"): + raw = default_settings() + raw["axes"]["x"]["dateFormat"] = ok + assert parse_settings(raw).axes.x.date_format == ok + msg = "「X軸の日時の書式」が正しくありません。%Y/%m/%d のように、% で始まる指定を含めてください。" + for bad in ("Y/m/d", "%Y" + "x" * 64, 5, ["%Y"]): + raw = default_settings() + raw["axes"]["x"]["dateFormat"] = bad + e = _err(raw) + assert e.message == msg and e.field == "X軸の日時の書式", bad + raw = default_settings() + raw["axes"]["y"]["dateFormat"] = "bad" # Y 軸には書式の項目が無い(無視される) + assert parse_settings(raw).axes.y.date_format == "" + + +def test_migrate_v2_to_v3(): + raw = _strip_v3(default_settings()) + raw["version"] = 2 + before = copy.deepcopy(raw) + out = migrate_settings(raw) + assert raw == before # 引数は変更しない + assert out["version"] == 3 and out["load"]["parseDates"] is False + s = parse_settings(raw) + assert s.version == 3 and s.load.parse_dates is False and s.load.skip_lines == 0 and s.load.decimal == "." + assert s.load.files == () and s.axes.x.date_format == "" and s.series[0].source == "" + assert s.save.dpi == 300 # v2 の dpi はそのまま + assert parse_load_settings(raw).parse_dates is False + assert parse_save_settings(raw).dpi == 300 + + +def test_migrate_v2_keeps_an_existing_parse_dates(): + raw = _strip_v3(default_settings()) + raw["version"] = 2 + raw["load"]["parseDates"] = True + assert migrate_settings(raw)["load"]["parseDates"] is True + raw = {"version": 2} + assert migrate_settings(raw) == {"version": 3, "load": {"parseDates": False}} + assert parse_settings({"version": 2}).load.parse_dates is False + + +def test_v1_migrates_through_to_v3(): + out = migrate_settings(_v1()) + assert out["version"] == 3 and out["save"]["dpi"] == 120 and out["load"]["parseDates"] is False + + +def test_v3_without_parse_dates_defaults_to_true(): + assert parse_settings({"version": 3}).load.parse_dates is True + assert parse_settings({}).load.parse_dates is True + raw = default_settings() + del raw["load"]["parseDates"] + assert migrate_settings(raw) == raw and parse_load_settings(raw).parse_dates is True