/** * WPML compatibility functions * * @global array $duplicated_posts Array to store the posts being duplicated. * * @package Yoast\WP\Duplicate_Post * @since 3.2 */ add_action( 'admin_init', 'duplicate_post_wpml_init' ); /** * Add handlers for WPML compatibility. */ function duplicate_post_wpml_init() { if ( defined( 'ICL_SITEPRESS_VERSION' ) ) { add_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10, 3 ); add_action( 'shutdown', 'duplicate_wpml_string_packages', 11 ); } } global $duplicated_posts; // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: Renaming a global variable is a BC break. $duplicated_posts = []; /** * Copy post translations. * * @global SitePress $sitepress Instance of the Main WPML class. * @global array $duplicated_posts Array of duplicated posts. * * @param int $post_id ID of the copy. * @param WP_Post $post Original post object. * @param string $status Status of the new post. */ function duplicate_post_wpml_copy_translations( $post_id, $post, $status = '' ) { global $sitepress; global $duplicated_posts; remove_action( 'dp_duplicate_page', 'duplicate_post_wpml_copy_translations', 10 ); remove_action( 'dp_duplicate_post', 'duplicate_post_wpml_copy_translations', 10 ); $current_language = $sitepress->get_current_language(); $trid = $sitepress->get_element_trid( $post->ID ); if ( ! empty( $trid ) ) { $translations = $sitepress->get_element_translations( $trid ); $new_trid = $sitepress->get_element_trid( $post_id ); foreach ( $translations as $code => $details ) { if ( $code !== $current_language ) { if ( $details->element_id ) { $translation = get_post( $details->element_id ); if ( ! $translation ) { continue; } $new_post_id = duplicate_post_create_duplicate( $translation, $status ); if ( ! is_wp_error( $new_post_id ) ) { $sitepress->set_element_language_details( $new_post_id, 'post_' . $translation->post_type, $new_trid, $code, $current_language ); } } } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: see above. $duplicated_posts[ $post->ID ] = $post_id; } } /** * Duplicate string packages. * * @global array() $duplicated_posts Array of duplicated posts. */ function duplicate_wpml_string_packages() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: renaming the function would be a BC-break. global $duplicated_posts; foreach ( $duplicated_posts as $original_post_id => $duplicate_post_id ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $original_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $original_post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. $new_string_packages = apply_filters( 'wpml_st_get_post_string_packages', false, $duplicate_post_id ); if ( is_array( $original_string_packages ) ) { foreach ( $original_string_packages as $original_string_package ) { $translated_original_strings = $original_string_package->get_translated_strings( [] ); foreach ( $new_string_packages as $new_string_package ) { $cache = new WPML_WP_Cache( 'WPML_Package' ); $cache->flush_group_cache(); $new_strings = $new_string_package->get_package_strings(); foreach ( $new_strings as $new_string ) { if ( isset( $translated_original_strings[ $new_string->name ] ) ) { foreach ( $translated_original_strings[ $new_string->name ] as $language => $translated_string ) { do_action( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- Reason: using WPML native filter. 'wpml_add_string_translation', $new_string->id, $language, $translated_string['value'], $translated_string['status'] ); } } } } } } } } カスタマイズのテスト方針CICD Devinの使い方 – Raqqa

カスタマイズのテスト方針CICD Devinの使い方

構成の全体像

  1. カスタマイズ台帳(Design Catalog)
  • 形式:YAML/JSON(Git管理)
  • 1レコード=1カスタマイズ(タイプごとにスキーマを持つ)
  • 主キー:custom_id(例: SO-SB-001)、xmlid(あれば)
  • 共通フィールド:type(view/smart_button/act_window/server_action/automation/approval/menu/field/model…)、modelsummaryownerrisk_leveljiradepends_oncreated_at
  • 変更の期待挙動(acceptance)とガード条件を記述(→テストの元)
  1. メタ収集スクリプト(Introspector)
  • Odoo API(fields_view_get(load='full')ir.model.fieldsir_ui_menuir.actions.*base.automation、Studio承認モデルなど)で 現状メタをスナップショット
  • 生成物:facts.json(実体メタ:タブ、スマートボタン、メニュー、アクション、オートメーション、承認ルール…)
  • Kubernetesなら CronJob で夜間に自動実行 → S3/Artifact へ保存 & GitにPR
  1. テスト生成器(Test Generator)
  • 入力:台帳(YAML)+ facts.json
  • 出力:テスト雛形:
    • ユニット/ロジックunittest/pytest(サーバアクション、ドメイン、compute等)
    • UI/遷移Playwright(スマートボタン/メニュー → 目的のビュー/ドメインに到達するか)
    • API:XML-RPC/JSON-RPC のコール例(権限・レスポンス)
    • 受け入れ(Gherkin):Cucumber的に人が読める仕様も並行生成
  • 生成規則はタイプ別テンプレートで実装
  1. CI/CD(検証ライン)
  • PRで台帳 or Odooモジュールが変わったら:
    1. Introspectorで facts.json 更新
    2. Test Generator が該当テストのみ再生成
    3. staging にデプロイ → Playwright/E2Epytest 実行
    4. レポート/失敗時は台帳IDでフィードバック

台帳のスキーマ例(YAML)

a) スマートボタン

- custom_id: SO-SB-001
  type: smart_button
  model: sale.order
  xmlid: my_mod.sb_sale_invoices
  selector:
    class: o_stat_button
    type: action
    name: sale.action_orders
  badge:
    count_field: invoice_count
  expect:
    navigate:
      action_xmlid: sale.action_orders
      target_model: sale.order
      view_mode: list,kanban,form
      domain_contains:
        - "('state','not in',('draft','sent','cancel'))"
    badge_non_negative: true
    groups_any_of:
      - account.group_account_user

b) 画面アクション(メニュー/遷移)

- custom_id: MENU-SALES-001
  type: act_window
  xmlid: sale.menu_sale_quotations
  res_model: sale.order
  view_mode: tree,form
  context:
    search_default_my_quotation: 1
  expect:
    opens: sale.order
    default_filters:
      - my_quotation

c) オートメーション + サーバアクション

- custom_id: SO-AUTO-001
  type: automation
  model: sale.order
  trigger: on_write
  filter_domain: "[('amount_total','>',100000)]"
  server_action_xmlid: my_mod.sa_notify_high_value
  expect:
    server_action:
      state: code
      side_effects:
        - "mail sent to group:sales_manager"

d) タブ(page)

- custom_id: SO-TAB-001
  type: tab
  model: sale.order
  page_name: order_lines           # 技術名
  page_label: Order Lines          # 表示
  o2m_field: order_line
  relation: sale.order.line
  inverse: order_id
  expect:
    visible: true
    editable_tree: true

台帳は「どう動くべきか」が中心。facts.json は「今どうなっているか」。


生成テストのテンプレート例

1) UI(Playwright, TypeScript)

  • スマートボタン → アクション遷移 & ドメイン適用
test('SO-SB-001: Invoices button navigates and filters', async ({ page }) => {
  await loginAs(page, 'account_user');
  await openForm(page, 'sale.order', sampleOrderId);

  const btn = page.locator('header .o_stat_button:has-text("Invoices")').first();
  await expect(btn).toBeVisible();
  const count = await btn.locator('.o_stat_value').innerText();
  expect(Number(count)).toBeGreaterThanOrEqual(0);

  await btn.click();
  await expect(page).toHaveURL(/#action=\d+&model=sale\.order/);
  // 代表フィルタの確認(例)
  await expect(page.locator('.o_searchview .o_facet:has-text("State")')).toBeVisible();
});

2) サーバアクション/オートメーション(pytest)

def test_SO_AUTO_001_high_value_triggers_mail(env):
    so = env['sale.order'].create({'partner_id': 1, 'order_line': [...], 'amount_total': 150000})
    so.write({'note': 'bump'})  # on_write trigger
    mails = env['mail.mail'].search([('subject','ilike','High value order'), ('res_id','=',so.id)])
    assert mails, "Expected notification mail"

3) Gherkin(読みやすい受け入れ)

Feature: SO-SB-001 Invoices Smart Button
  Scenario: Navigate to Orders from the Invoices stat button
    Given I am logged in as "account_user"
    And I open a Sale Order form with invoices
    When I click the "Invoices" smart button
    Then I should see the Orders list view
    And the search should exclude states "draft", "sent", "cancel"

Introspector(抽出)で集める“事実”の例(facts.json)

  • tabs: { view_id, page_name, label, o2m_fields: [{name, relation, inverse}], ... }
  • smart_buttons: { view_id, label, type, name(xmlid/ID), groups, stat_field, action_resolved: {...} }
  • actions: ir.actions.* のすべて(res_model, view_mode, domain, context, target
  • menus: ir_ui_menu + groups + actionリンク
  • automations: base_automation + ir_act_server
  • approvals(Studio):ルールとログ(モデル名は環境探索)

これを Gitにコミット しておけば、差分=回帰テストの対象。


Kubernetes運用のひな型

  • Namespace: odoo-tools
  • Secret: 認証情報(URL/DB/USER/PASS)
  • CronJob:
    1. python:3.11-slim 起動
    2. pip install lxml playwright
    3. Introspectorで facts.json 生成
    4. Generatorで tests/ を再生成
    5. playwright install --with-deps
    6. staging Odoo に対してテスト実行
    7. 成果物とレポートを Artifact/S3 へ、失敗は Slack/メール通知
  • Pipeline: PR時は該当 custom_id のテストだけ部分実行

実装のコツ(抜粋)

  • タブの識別は @name(技術名)固定。ラベルは翻訳で変わるのでキーにしない。
  • スマートボタンは class="o_stat_button" + type="action" + XMLID。バッジは statinfo + *_count
  • ドメイン/コンテキストは Python文字列なので、テストでは 部分一致(contains) で検証。
  • Studio承認は環境差が出やすいので、まず ORMでモデル名を列挙→read
  • テストデータdemoタグ 付きのファクトリで用意(ゼロ件でもボタンが出る/出ないを検証)。

まとめ

  • **台帳(要件)事実(facts.json)**を分離し、テンプレートでテスト自動生成するのが再現性と保守性に優れます。
  • 「どう動くべきか」は人が書き、「今どうなってるか」は機械が取る。CIで両者を突き合わせれば回帰に強い。
  • ここまで準備すれば、**新しいカスタマイズも“書けば自動でテストが増える”**体制になります。

必要なら、台帳YAMLの最小スキーマと、facts抽出・テスト生成の実装雛形(Python)をすぐお渡しします。

あなた:

カスタマイズ台帳(Design Catalog)はこちらが作って、後はdevinがやる、というのはどうでしょうか。

ChatGPT:

いいと思います。その分業が一番回しやすいです。
あなたは台帳(Design Catalog)=仕様の唯一のソースだけを整備し、あとは「Devin(自動実装/自動テスト生成のワーカー)」が抽出→テスト生成→実行→レポートを回す形にします。

役割分担(超要約)

  • あなた(人):台帳を書く・更新する(YAML/JSON)。レビューして承認する。
  • Devin:facts抽出(Odoo API)、台帳と突合、テスト生成、K8s上で実行、レポート、PR作成。

リポジトリ構成(最小)

repo/
├─ catalog/                      # ← あなたの守備範囲
│  ├─ smart_buttons.yaml
│  ├─ tabs.yaml
│  ├─ actions.yaml               # act_window / report / client / url
│  ├─ automations.yaml           # base.automation + ir_act_server
│  ├─ approvals.yaml             # Studio 承認(あれば)
│  └─ meta.yaml                  # 命名規約・共通定義
├─ tools/                        # Devin 管理(自動生成OK)
│  ├─ introspect/                # fields_view_get 等で facts.json を作る
│  ├─ generator/                 # catalog + facts -> tests を生成
│  └─ runners/                   # pytest / Playwright 実行ラッパ
├─ facts/                        # Devinが nightly に更新(生成物)
│  └─ odoo18-staging-facts.json
├─ tests/                        # 生成されたテスト(コミットされてもOK)
└─ .github/workflows/ci.yml      # Devin が回すCI(K8s実行でも可)

台帳スキーマ(抜粋:そのまま流用OK)

共通(catalog/meta.yaml)

version: 1
defaults:
  lang: ja_JP
  env: staging
naming:
  id_prefixes:
    smart_button: SB
    tab: TAB
    action: ACT
    automation: AUTO
  rules:
    - "custom_id は {PREFIX}-{model}-{seq:03d} 例: SB-sale.order-001"
conventions:
  page_name_must_exist: true
  smart_button_must_use_action_xmlid: true

スマートボタン(catalog/smart_buttons.yaml)

- custom_id: SB-sale.order-001
  model: sale.order
  selector:
    class: o_stat_button
    type: action
    name_xmlid: sale.action_orders      # 数値ID禁止
  label: Invoices
  badge:
    field: invoice_count                # statinfo のフィールド
  expect:
    action_type: ir.actions.act_window
    target_model: sale.order
    view_mode_contains: ["list","form"]
    domain_contains:
      - "('state','not in',('draft','sent','cancel'))"
    groups_any_of:
      - account.group_account_user

タブ(catalog/tabs.yaml)

- custom_id: TAB-sale.order-001
  model: sale.order
  page_name: order_lines                # 技術名を必須化
  label: Order Lines
  o2m_field: order_line
  relation: sale.order.line
  inverse: order_id
  expect:
    visible: true
    editable_tree: true

画面アクション(catalog/actions.yaml)

- custom_id: ACT-sale.orders
  type: act_window
  xmlid: sale.action_orders
  res_model: sale.order
  view_mode: [list, kanban, form]
  context:
    search_default_my_quotation: 1
  expect:
    opens_model: sale.order

オートメーション(catalog/automations.yaml)

- custom_id: AUTO-sale.order-001
  model: sale.order
  trigger: on_write
  domain: "[('amount_total','>',100000)]"
  server_action_xmlid: my_mod.sa_notify_high_value
  expect:
    server_action_state: code
    observable_effects:
      - "mail sent to group:sales_manager"

Devin 側の仕事(自動)

  1. facts 抽出(tools/introspect)
    • fields_view_get(load='full') で最終XML+fields
    • ir_ui_menuir.actions.*base_automationir_act_server
    • Studio承認:存在すれば studio.approval.* を ORM で探索
    • 生成:facts/odoo18-<env>-facts.json
  2. 突合/検証
    • catalogの each custom_id を facts で解決(XMLID/モデル名一致)
    • 欠落・不一致は PR コメントに差分を列挙
  3. テスト生成(tools/generator)
    • UI系:Playwright(スマートボタン、メニュー、タブ可視性)
    • ロジック系:pytest(サーバアクション、オートメーション)
    • 受入仕様:Gherkinもオプションで生成
  4. 実行 & レポート
    • K8s Job(python:3.11 + playwright)で実行
    • 失敗は custom_id 単位で報告(Slack/PRアノテーション)

CI/Kubernetes ざっくり

  • Secret: ODOO_URL, DB, USER, PASS(staging/prod 分離)
  • Nightly CronJob: facts 更新 → テスト再生成 → 実行 → レポート保存
  • PR時:変更された catalog/*custom_id だけ再生成・実行(高速)

運用ルール(これだけ守れば楽)

  • タブは @name(技術名)を必ず固定。ラベルは変えてOK。
  • スマートボタンは type="action" + XMLID、バッジは statinfo + *_count
  • サーバアクションは XMLID付与、Automation からは XMLID参照
  • 台帳の expect.*厳格すぎず“部分一致”で書く(domain/contextは文字列なので)

まず最初にやること(今日できる)

  1. この回答の YAMLスキーマを catalog/ に置く
  2. あなたが 既存の重要カスタム 3件だけ記入
  3. Devinに facts抽出→テスト生成→実行を1回回させる
  4. レポートを見て expect を微調整 → 収束

この流れにしておけば、**「台帳を書けばテストが勝手に増える」**状態にできます。必要なら、最初のfacts抽出スクリプトと生成テンプレートをすぐ用意します。


Comments

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です